From d1ac7c7aa690522716a1716b8e4e0f07135564ce Mon Sep 17 00:00:00 2001 From: Gabriel Diaz Date: Tue, 23 Jun 2026 19:22:52 +0200 Subject: [PATCH 1/3] feat: authoritative-server (Hammurabi) comms for Portable Experiences An authoritativeMultiplayer Portable Experience never joined a scene comms room, so the world-content-server never spawned its authoritative server (Hammurabi) and no CRDT was exchanged. PX realms run with an empty WorldManifest (RealmData.SingleScene == true), which routed comms to the world-level room that carries no sceneId. - PortableExperienceSceneRoom: scene-level LiveKit connection to worlds/{realm}/scenes/{sceneId}/comms, carrying the sceneId the server needs to spawn the authoritative server. - PortableExperienceWorldComms: owns one room + message pipe per running authoritative PX scene, keyed by sceneId, reconciled on load/unload. - SceneCommunicationPipe: routes a registered PX scene's CRDT to/from its own room instead of the host's current scene room (hoisted out of SceneSharedContainer into CommsContainer so global systems can reach it). - PortableExperienceWorldCommsSystem/Plugin: connects and registers authoritative PX scenes each frame. - WriteRealmInfoSystem: reports isConnectedSceneRoom for PX scenes so the SDK initiates the REQ/RES CRDT-state handshake. Docs: authoritative-portable-experiences.md. --- .../Communications/SceneCommunicationPipe.cs | 90 ++++++- .../Global/Dynamic/Bootstraper.cs | 2 +- .../Global/Dynamic/CommsContainer.cs | 26 +- .../Global/Dynamic/DynamicWorldContainer.cs | 7 +- .../Global/SceneSharedContainer.cs | 5 +- .../Tests/PlayMode/IntegrationTestsSuite.cs | 3 +- .../Connections/PortableExperiences.meta | 8 + .../PortableExperienceSceneRoom.cs | 79 ++++++ .../PortableExperienceSceneRoom.cs.meta | 2 + .../PortableExperienceWorldComms.cs | 162 ++++++++++++ .../PortableExperienceWorldComms.cs.meta | 2 + .../Global/PortableExperienceComms.meta | 8 + .../PortableExperienceCommsPlugin.cs | 29 ++ .../PortableExperienceCommsPlugin.cs.meta | 2 + .../PortableExperienceWorldCommsSystem.cs | 85 ++++++ ...PortableExperienceWorldCommsSystem.cs.meta | 2 + .../DCL/PluginSystem/World/RealmInfoPlugin.cs | 7 +- .../Tests/WriteRealmInfoSystemShould.cs | 9 +- .../RealmInfo/WriteRealmInfoSystem.cs | 15 +- docs/README.md | 1 + docs/authoritative-portable-experiences.md | 248 ++++++++++++++++++ 21 files changed, 773 insertions(+), 19 deletions(-) create mode 100644 Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences.meta create mode 100644 Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceSceneRoom.cs create mode 100644 Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceSceneRoom.cs.meta create mode 100644 Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceWorldComms.cs create mode 100644 Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceWorldComms.cs.meta create mode 100644 Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms.meta create mode 100644 Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs create mode 100644 Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs.meta create mode 100644 Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs create mode 100644 Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs.meta create mode 100644 docs/authoritative-portable-experiences.md diff --git a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Communications/SceneCommunicationPipe.cs b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Communications/SceneCommunicationPipe.cs index 5f6647457bf..5386cc8dd08 100644 --- a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Communications/SceneCommunicationPipe.cs +++ b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Communications/SceneCommunicationPipe.cs @@ -1,7 +1,8 @@ -using DCL.Multiplayer.Connections.GateKeeper.Rooms; +using DCL.Multiplayer.Connections.GateKeeper.Rooms; using DCL.Multiplayer.Connections.Messaging; using DCL.Multiplayer.Connections.Messaging.Hubs; using DCL.Multiplayer.Connections.Messaging.Pipe; +using DCL.Multiplayer.Connections.Rooms.Connective; using Decentraland.Kernel.Comms.Rfc4; using Google.Protobuf; using DCL.LiveKit.Public; @@ -24,6 +25,11 @@ public class SceneCommunicationPipe : ISceneCommunicationPipe private readonly IGateKeeperSceneRoom sceneRoom; private readonly IMessagePipe messagePipe; + // Additional per-scene rooms (e.g. authoritative Portable Experience scene rooms) keyed by sceneId. When a + // sceneId has an entry here, its comms are routed to/from that room instead of the host's current scene room. + private readonly Dictionary extraRoomsBySceneId = new (); + private readonly List staleSceneIdsBuffer = new (); + public SceneCommunicationPipe(IMessagePipesHub messagePipesHub, IGateKeeperSceneRoom sceneRoom) { this.sceneRoom = sceneRoom; @@ -31,6 +37,43 @@ public SceneCommunicationPipe(IMessagePipesHub messagePipesHub, IGateKeeperScene messagePipe.Subscribe(Packet.MessageOneofCase.Scene, InvokeSubscriber, IMessagePipe.ThreadStrict.ORIGIN_THREAD); } + /// + /// Routes the given scene's comms to a dedicated room (its own message pipe) instead of the host's current + /// scene room. Idempotent. The caller owns the room and pipe lifecycle; only + /// stops routing. + /// + public void RegisterSceneRoom(string sceneId, IConnectiveRoom room, IMessagePipe roomPipe) + { + lock (extraRoomsBySceneId) + { + if (extraRoomsBySceneId.ContainsKey(sceneId)) return; + extraRoomsBySceneId[sceneId] = new RoomChannel(room, roomPipe); + } + + roomPipe.Subscribe(Packet.MessageOneofCase.Scene, InvokeSubscriber, IMessagePipe.ThreadStrict.ORIGIN_THREAD); + } + + /// + /// Stops routing for any registered scene room whose sceneId is no longer present in + /// . Disposing the underlying room/pipe is the caller's responsibility. + /// + public void RetainOnlyRooms(ICollection liveSceneIds) + { + lock (extraRoomsBySceneId) + { + if (extraRoomsBySceneId.Count == 0) return; + + staleSceneIdsBuffer.Clear(); + + foreach (string sceneId in extraRoomsBySceneId.Keys) + if (!liveSceneIds.Contains(sceneId)) + staleSceneIdsBuffer.Add(sceneId); + + foreach (string sceneId in staleSceneIdsBuffer) + extraRoomsBySceneId.Remove(sceneId); + } + } + private void InvokeSubscriber(ReceivedMessage message) { using (message) @@ -44,7 +87,7 @@ private void InvokeSubscriber(ReceivedMessage message) // TODO: If the room is connected but the scene is not connected **yet** the message will be skipped and forgotten - if (!sceneRoom.IsSceneConnected(message.Payload.SceneId)) return; + if (!IsSceneConnected(message.Payload.SceneId)) return; SubscriberKey key = new (message.Payload.SceneId, msgType); @@ -108,9 +151,23 @@ public void RemoveSceneMessageHandler(string sceneId, ISceneCommunicationPipe.Ms public void SendMessage(ReadOnlySpan message, string sceneId, ISceneCommunicationPipe.ConnectivityAssertiveness assertiveness, CancellationToken ct, string? specialRecipient = null) { - if (!sceneRoom.IsSceneConnected(sceneId)) return; + IMessagePipe pipe; - MessageWrap sceneMessage = messagePipe.NewMessage(); + lock (extraRoomsBySceneId) + { + if (extraRoomsBySceneId.TryGetValue(sceneId, out RoomChannel channel)) + { + if (!IsRoomConnected(channel.Room)) return; + pipe = channel.Pipe; + } + else + { + if (!sceneRoom.IsSceneConnected(sceneId)) return; + pipe = messagePipe; + } + } + + MessageWrap sceneMessage = pipe.NewMessage(); if (!string.IsNullOrEmpty(specialRecipient)) sceneMessage.AddSpecialRecipient(specialRecipient); @@ -120,6 +177,19 @@ public void SendMessage(ReadOnlySpan message, string sceneId, ISceneCommun sceneMessage.SendAndDisposeAsync(ct, LKDataPacketKind.KindReliable).Forget(); } + private bool IsSceneConnected(string sceneId) + { + lock (extraRoomsBySceneId) + if (extraRoomsBySceneId.TryGetValue(sceneId, out RoomChannel channel)) + return IsRoomConnected(channel.Room); + + return sceneRoom.IsSceneConnected(sceneId); + } + + private static bool IsRoomConnected(IConnectiveRoom room) => + room.CurrentState() == IConnectiveRoom.State.Running + && room.Room().Info.ConnectionState == LKConnectionState.ConnConnected; + private readonly struct SubscriberKey : IEquatable { public readonly string SceneId; @@ -140,5 +210,17 @@ public override bool Equals(object? obj) => public override int GetHashCode() => HashCode.Combine(SceneId, (int)MsgType); } + + private readonly struct RoomChannel + { + public readonly IConnectiveRoom Room; + public readonly IMessagePipe Pipe; + + public RoomChannel(IConnectiveRoom room, IMessagePipe pipe) + { + Room = room; + Pipe = pipe; + } + } } } diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs index 58fa3365957..6e966ebaf81 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/Bootstraper.cs @@ -256,7 +256,7 @@ Entity playerEntity dynamicWorldContainer.ProfileRepository, dynamicWorldContainer.RoomHub, dynamicWorldContainer.MvcManager, - dynamicWorldContainer.MessagePipesHub, + dynamicWorldContainer.SceneCommunicationPipe, dynamicWorldContainer.RemoteMetadata, webJsSources, bootstrapContainer.Environment, diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs index ecabc6149d5..42be78edf82 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs @@ -1,5 +1,6 @@ using Arch.Core; using CommunicationData.URLHelpers; +using CrdtEcsBridge.JsModulesImplementation.Communications; using DCL.AssetsProvision; using DCL.DebugUtilities; using DCL.LiveKit.Public; @@ -12,6 +13,7 @@ using DCL.Multiplayer.Connections.GateKeeper.Rooms.Options; using DCL.Multiplayer.Connections.Messaging.Hubs; using DCL.Multiplayer.Connections.Pools; +using DCL.Multiplayer.Connections.PortableExperiences; using DCL.Multiplayer.Connections.RoomHubs; using DCL.Multiplayer.Connections.Rooms.Connective; using DCL.Multiplayer.Connections.Rooms.Status; @@ -68,6 +70,10 @@ public class CommsContainer : IDisposable public CurrentSceneInfo CurrentSceneInfo { get; } + public PortableExperienceWorldComms PortableExperienceWorldComms { get; } + + public SceneCommunicationPipe SceneCommunicationPipe { get; } + private CommsContainer( IArchipelagoIslandRoom archipelagoIslandRoom, IGateKeeperSceneRoom gateKeeperSceneRoom, @@ -83,7 +89,9 @@ private CommsContainer( RemoteEntities remoteEntities, IRemoteMetadata remoteMetadata, IHealthCheck livekitHealthCheck, - CurrentSceneInfo currentSceneInfo) + CurrentSceneInfo currentSceneInfo, + PortableExperienceWorldComms portableExperienceWorldComms, + SceneCommunicationPipe sceneCommunicationPipe) { this.archipelagoIslandRoom = archipelagoIslandRoom; this.gateKeeperSceneRoom = gateKeeperSceneRoom; @@ -100,6 +108,8 @@ private CommsContainer( RemoteMetadata = remoteMetadata; LivekitHealthCheck = livekitHealthCheck; CurrentSceneInfo = currentSceneInfo; + PortableExperienceWorldComms = portableExperienceWorldComms; + SceneCommunicationPipe = sceneCommunicationPipe; } public static CommsContainer Create( @@ -201,6 +211,15 @@ public static CommsContainer Create( ? livekitHealthCheck.WithFailAnalytics(bootstrapContainer.Analytics.Controller) : livekitHealthCheck; + var portableExperienceWorldComms = new PortableExperienceWorldComms( + staticContainer.WebRequestsContainer.WebRequestController, + identityCache, + bootstrapContainer.DecentralandUrlsSource, + MultiPoolFactory(), + new ArrayMemoryPool(ArrayPool.Shared!)); + + var sceneCommunicationPipe = new SceneCommunicationPipe(messagePipesHub, roomHub.SceneRoom()); + return new CommsContainer( archipelagoIslandRoom, gateKeeperSceneRoom, @@ -216,7 +235,9 @@ public static CommsContainer Create( remoteEntities, remoteMetadata, livekitHealthCheck, - new CurrentSceneInfo()); + new CurrentSceneInfo(), + portableExperienceWorldComms, + sceneCommunicationPipe); } public MultiplayerPlugin CreateMultiplayerPlugin( @@ -259,6 +280,7 @@ public ConnectionStatusPanelPlugin CreateConnectionStatusPanelPlugin(IAssetsProv public void Dispose() { MessagePipesHub.Dispose(); + PortableExperienceWorldComms.Dispose(); } private static IMultiPool MultiPoolFactory() => diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs index 369c19a79d1..7dd447097fe 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs @@ -48,8 +48,10 @@ using DCL.Optimization.AdaptivePerformance.Systems; using DCL.PerformanceAndDiagnostics.Analytics; using DCL.PerformanceAndDiagnostics.Analytics.DecoratorBased; +using CrdtEcsBridge.JsModulesImplementation.Communications; using DCL.PluginSystem; using DCL.PluginSystem.Global; +using DCL.PluginSystem.Global.PortableExperienceComms; using DCL.PluginSystem.SmartWearables; using DCL.PluginSystem.World; using DCL.PrivateWorlds; @@ -130,6 +132,8 @@ public class DynamicWorldContainer : DCLWorldContainer public IRoomHub RoomHub => commsContainer.RoomHub; + public SceneCommunicationPipe SceneCommunicationPipe => commsContainer.SceneCommunicationPipe; + public ISystemClipboard SystemClipboard => uiShellContainer.Clipboard; private DynamicWorldContainer( @@ -442,7 +446,7 @@ await MapRendererContainer { new AvatarAttachPlugin(globalWorld, staticContainer.MainPlayerAvatarBaseProxy, staticContainer.ComponentsContainer.ComponentPoolsRegistry, commsContainer.EntityParticipantTable, staticContainer.CharacterContainer.Transform), new SceneMaskedEmotePlugin(globalWorld, playerEntity, staticContainer.MainPlayerAvatarBaseProxy, staticContainer.EmotesContainer.EmotePlayer, staticContainer.EmoteStorage, multiplayerEmotesMessageBus), - new RealmInfoPlugin(staticContainer.RealmData, commsContainer.RoomHub), + new RealmInfoPlugin(staticContainer.RealmData, commsContainer.RoomHub, commsContainer.PortableExperienceWorldComms), }; var characterPreviewEventBus = new CharacterPreviewEventBus(); @@ -521,6 +525,7 @@ await MapRendererContainer new AdaptivePerformancePlugin(staticContainer.Profiler, staticContainer.LoadingStatus), new LightSourceDebugPlugin(staticContainer.DebugContainerBuilder, globalWorld), commsContainer.CreateMultiplayerPlugin(staticContainer, assetsProvisioner, debugBuilder, multiplayerContainer), + new PortableExperienceCommsPlugin(commsContainer.PortableExperienceWorldComms, commsContainer.SceneCommunicationPipe), staticContainer.ProfilesContainer.CreatePlugin(), new WorldInfoPlugin(realmNavigatorContainer.WorldInfoHub, debugBuilder, chatContainer.ChatHistory), new CharacterMotionPlugin(staticContainer.RealmData, staticContainer.CharacterContainer.CharacterObject, debugBuilder, staticContainer.ComponentsContainer.ComponentPoolsRegistry, diff --git a/Explorer/Assets/DCL/Infrastructure/Global/SceneSharedContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/SceneSharedContainer.cs index 80d0e03a48d..5a7fca4d4ae 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/SceneSharedContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/SceneSharedContainer.cs @@ -3,7 +3,6 @@ using CrdtEcsBridge.PoolsProviders; using DCL.PluginSystem.World; using DCL.Multiplayer.Connections.DecentralandUrls; -using DCL.Multiplayer.Connections.Messaging.Hubs; using DCL.Multiplayer.Connections.RoomHubs; using DCL.Multiplayer.Profiles.Poses; using DCL.PluginSystem.World.Dependencies; @@ -44,7 +43,7 @@ public static SceneSharedContainer Create(in StaticContainer staticContainer, IProfileRepository profileRepository, IRoomHub roomHub, IMVCManager mvcManager, - IMessagePipesHub messagePipesHub, + ISceneCommunicationPipe sceneCommunicationPipe, IRemoteMetadata remoteMetadata, IWebJsSources webJsSources, DecentralandEnvironment dclEnvironment, @@ -83,7 +82,7 @@ public static SceneSharedContainer Create(in StaticContainer staticContainer, realmData, staticContainer.PortableExperiencesController, staticContainer.StaticSettings.SkyboxSettings, - new SceneCommunicationPipe(messagePipesHub, roomHub.SceneRoom()), + sceneCommunicationPipe, remoteMetadata, dclEnvironment, systemClipboard, diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Tests/PlayMode/IntegrationTestsSuite.cs b/Explorer/Assets/DCL/Infrastructure/Global/Tests/PlayMode/IntegrationTestsSuite.cs index 9a32fbb59fe..4310653d252 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Tests/PlayMode/IntegrationTestsSuite.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Tests/PlayMode/IntegrationTestsSuite.cs @@ -8,6 +8,7 @@ using DCL.Diagnostics; using DCL.FeatureFlags; using DCL.Multiplayer.Connections.DecentralandUrls; +using CrdtEcsBridge.JsModulesImplementation.Communications; using DCL.Multiplayer.Connections.Messaging.Hubs; using DCL.Multiplayer.Connections.RoomHubs; using DCL.Multiplayer.Profiles.Poses; @@ -138,7 +139,7 @@ public static class IntegrationTestsSuite new CancellationTokenSource(), Substitute.For() ), - new IMessagePipesHub.Fake(), + Substitute.For(), Substitute.For(), webJsSources, DecentralandEnvironment.Org, diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences.meta b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences.meta new file mode 100644 index 00000000000..ec724a5826b --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ab862128a6be94812b385e5fb9b46d69 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceSceneRoom.cs b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceSceneRoom.cs new file mode 100644 index 00000000000..e6d7f0dbb29 --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceSceneRoom.cs @@ -0,0 +1,79 @@ +using Cysharp.Threading.Tasks; +using DCL.Diagnostics; +using DCL.LiveKit.Public; +using DCL.Multiplayer.Connections.DecentralandUrls; +using DCL.Multiplayer.Connections.GateKeeper.Meta; +using DCL.Multiplayer.Connections.Rooms.Connective; +using DCL.Web3.Identities; +using DCL.WebRequests; +using ECS; +using System; +using System.Threading; +using UnityEngine; + +namespace DCL.Multiplayer.Connections.PortableExperiences +{ + /// + /// A scene-level LiveKit comms connection to a Portable Experience world's scene room + /// (worlds/{realm}/scenes/{sceneId}/comms). The join carries the sceneId, which is what the + /// world-content-server's room-event-processor needs to spawn the authoritative server for the experience. + /// Unlike GateKeeperSceneRoom it always targets the world scene endpoint: a PX realm is configured with an + /// empty WorldManifest (so RealmData.SingleScene == true), which would otherwise route comms to the + /// world-level room that carries no sceneId. + /// + public class PortableExperienceSceneRoom : ConnectiveRoom + { + private readonly IWebRequestController webRequests; + private readonly IWeb3IdentityCache identityCache; + private readonly IDecentralandUrlsSource urlsSource; + private readonly IRealmData realmData; + private readonly string sceneId; + + public PortableExperienceSceneRoom(IWebRequestController webRequests, IWeb3IdentityCache identityCache, IDecentralandUrlsSource urlsSource, IRealmData realmData, string sceneId) + { + this.webRequests = webRequests; + this.identityCache = identityCache; + this.urlsSource = urlsSource; + this.realmData = realmData; + this.sceneId = sceneId; + } + + protected override UniTask PrewarmAsync(CancellationToken token) => + UniTask.CompletedTask; + + protected override async UniTask CycleStepAsync(CancellationToken token) + { + // Skip if identity is not available (e.g., during sign-out). + if (identityCache.Identity == null) + return; + + if (CurrentState() is not IConnectiveRoom.State.Running + || Room().Info.ConnectionState != LKConnectionState.ConnConnected) + { + string connectionString = await ConnectionStringAsync(token); + await TryConnectToRoomAsync(connectionString, token); + } + } + + private async UniTask ConnectionStringAsync(CancellationToken token) + { + string url = string.Format(urlsSource.Url(DecentralandUrl.WorldCommsAdapter), realmData.RealmName, sceneId); + var meta = new MetaData(sceneId, Vector2Int.zero, new MetaData.Input(realmData.RealmName, Vector2Int.zero)); + + ReportHub.Log(ReportCategory.COMMS_SCENE_HANDLER, $"[PortableExperienceSceneRoom] Requesting adapter from '{url}' for scene '{sceneId}'"); + + AdapterResponse response = await webRequests + .SignedFetchPostAsync(url, meta.BuildWithSecret(realmData.WorldCommsSecret), token) + .CreateFromJson(WRJsonParser.Unity); + + return string.IsNullOrEmpty(response.adapter) ? response.fixedAdapter : response.adapter; + } + + [Serializable] + private struct AdapterResponse + { + public string adapter; + public string fixedAdapter; + } + } +} diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceSceneRoom.cs.meta b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceSceneRoom.cs.meta new file mode 100644 index 00000000000..2ddfee91a6c --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceSceneRoom.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f07be5e8926614100957da02ab4056c8 \ No newline at end of file diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceWorldComms.cs b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceWorldComms.cs new file mode 100644 index 00000000000..fed32030cf8 --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceWorldComms.cs @@ -0,0 +1,162 @@ +using Cysharp.Threading.Tasks; +using DCL.Diagnostics; +using DCL.LiveKit.Public; +using DCL.Multiplayer.Connections.DecentralandUrls; +using DCL.Multiplayer.Connections.Messaging.Pipe; +using DCL.Multiplayer.Connections.Messaging.Throughput; +using DCL.Multiplayer.Connections.Rooms; +using DCL.Multiplayer.Connections.Rooms.Connective; +using DCL.Multiplayer.Connections.Systems.Throughput; +using DCL.Web3.Identities; +using DCL.WebRequests; +using ECS; +using LiveKit.Internal.FFIClients.Pools; +using LiveKit.Internal.FFIClients.Pools.Memory; +using System; +using System.Collections.Generic; + +namespace DCL.Multiplayer.Connections.PortableExperiences +{ + /// + /// Owns, per running authoritative Portable Experience scene, a scene-level LiveKit connection + /// () plus a over its data channel. + /// Joining the scene room carries the sceneId, which makes the world-content-server spawn the authoritative + /// server (Hammurabi). Connections are keyed by sceneId (entity hash) and reconciled against the set of + /// currently-loaded authoritative scenes via . The message pipe is exposed via + /// so the scene comms multiplexer can route that scene's CRDT to/from the server; + /// this class never references the scene-runtime pipe (assembly direction is SceneRuntime → DCL.Multiplayer). + /// + public class PortableExperienceWorldComms : IDisposable + { + private readonly IWebRequestController webRequests; + private readonly IWeb3IdentityCache identityCache; + private readonly IDecentralandUrlsSource urlsSource; + private readonly IMultiPool multiPool; + private readonly IMemoryPool memoryPool; + private readonly Dictionary entriesBySceneId = new (); + private readonly List staleSceneIdsBuffer = new (); + + public PortableExperienceWorldComms(IWebRequestController webRequests, IWeb3IdentityCache identityCache, IDecentralandUrlsSource urlsSource, + IMultiPool multiPool, IMemoryPool memoryPool) + { + this.webRequests = webRequests; + this.identityCache = identityCache; + this.urlsSource = urlsSource; + this.multiPool = multiPool; + this.memoryPool = memoryPool; + } + + public void Dispose() + { + foreach (Entry entry in entriesBySceneId.Values) + DisposeEntry(entry); + + entriesBySceneId.Clear(); + } + + /// + /// Connects to the scene room of the experience the first time it is requested for a sceneId. Idempotent: + /// repeated calls for an already-connected sceneId are ignored. + /// + public void EnsureConnected(string sceneId, IRealmData portableExperienceRealm) + { + if (entriesBySceneId.ContainsKey(sceneId)) return; + + var room = new PortableExperienceSceneRoom(webRequests, identityCache, urlsSource, portableExperienceRealm, sceneId); + IMessagePipe pipe = new MessagePipe(room.Room().DataPipe.WithThroughputMeasure(new ThroughputBufferBunch(new ThroughputBuffer(), new ThroughputBuffer())), + multiPool, memoryPool, RoomSource.GATEKEEPER) + .WithLog("PortableExperienceScene"); + + entriesBySceneId[sceneId] = new Entry(room, pipe); + + ReportHub.Log(ReportCategory.COMMS_SCENE_HANDLER, $"Portable Experience comms: connecting scene '{sceneId}' of world '{portableExperienceRealm.RealmName}'"); + ConnectAsync(sceneId, room).Forget(); + } + + /// + /// Returns the LiveKit room and its message pipe for a connected scene, regardless of connection state. + /// + public bool TryGetRoom(string sceneId, out IConnectiveRoom room, out IMessagePipe pipe) + { + if (entriesBySceneId.TryGetValue(sceneId, out Entry entry)) + { + room = entry.Room; + pipe = entry.Pipe; + return true; + } + + room = null!; + pipe = null!; + return false; + } + + /// + /// True when the scene's Portable Experience room exists and its LiveKit connection is established. Used to + /// report RealmInfo.isConnectedSceneRoom for PX scenes so the SDK initiates the CRDT handshake. + /// + public bool IsConnected(string sceneId) => + entriesBySceneId.TryGetValue(sceneId, out Entry entry) + && entry.Room.CurrentState() == IConnectiveRoom.State.Running + && entry.Room.Room().Info.ConnectionState == LKConnectionState.ConnConnected; + + /// + /// Disconnects and disposes every scene whose sceneId is no longer present in . + /// + public void RetainOnly(ICollection liveSceneIds) + { + if (entriesBySceneId.Count == 0) return; + + staleSceneIdsBuffer.Clear(); + + foreach (string sceneId in entriesBySceneId.Keys) + if (!liveSceneIds.Contains(sceneId)) + staleSceneIdsBuffer.Add(sceneId); + + foreach (string sceneId in staleSceneIdsBuffer) + { + Entry entry = entriesBySceneId[sceneId]; + entriesBySceneId.Remove(sceneId); + + ReportHub.Log(ReportCategory.COMMS_SCENE_HANDLER, $"Portable Experience comms: disconnecting scene '{sceneId}'"); + DisposeEntry(entry); + } + } + + private static void DisposeEntry(Entry entry) + { + entry.Pipe.Dispose(); + StopAndDisposeAsync(entry.Room).Forget(); + } + + private static async UniTaskVoid ConnectAsync(string sceneId, IConnectiveRoom room) + { + try + { + bool connected = await room.StartIfNotAsync(); + ReportHub.Log(ReportCategory.COMMS_SCENE_HANDLER, $"Portable Experience comms: scene '{sceneId}' connection finished, connected={connected}"); + } + catch (OperationCanceledException) { } + catch (Exception e) { ReportHub.LogException(e, new ReportData(ReportCategory.COMMS_SCENE_HANDLER)); } + } + + private static async UniTaskVoid StopAndDisposeAsync(IConnectiveRoom room) + { + try { await room.StopIfNotAsync(); } + catch (OperationCanceledException) { } + catch (Exception e) { ReportHub.LogException(e, new ReportData(ReportCategory.COMMS_SCENE_HANDLER)); } + finally { room.Dispose(); } + } + + private readonly struct Entry + { + public readonly IConnectiveRoom Room; + public readonly IMessagePipe Pipe; + + public Entry(IConnectiveRoom room, IMessagePipe pipe) + { + Room = room; + Pipe = pipe; + } + } + } +} diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceWorldComms.cs.meta b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceWorldComms.cs.meta new file mode 100644 index 00000000000..6b814822d1c --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceWorldComms.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: efb5838e4fcf243ad96c2bf483571349 \ No newline at end of file diff --git a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms.meta b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms.meta new file mode 100644 index 00000000000..d6209dc7ffc --- /dev/null +++ b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2e72d296e0bd6498db14b18f37495c40 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs new file mode 100644 index 00000000000..b38a9977963 --- /dev/null +++ b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs @@ -0,0 +1,29 @@ +using Arch.SystemGroups; +using CrdtEcsBridge.JsModulesImplementation.Communications; +using DCL.Multiplayer.Connections.PortableExperiences; + +namespace DCL.PluginSystem.Global.PortableExperienceComms +{ + /// + /// Global plugin that injects so running authoritative Portable + /// Experiences connect to their world scene room (which makes the world-content-server spawn the authoritative + /// server) and exchange CRDT with it over that room. The underlying connections are owned and disposed by + /// CommsContainer via . + /// + public class PortableExperienceCommsPlugin : IDCLGlobalPluginWithoutSettings + { + private readonly PortableExperienceWorldComms worldComms; + private readonly SceneCommunicationPipe sceneCommunicationPipe; + + public PortableExperienceCommsPlugin(PortableExperienceWorldComms worldComms, SceneCommunicationPipe sceneCommunicationPipe) + { + this.worldComms = worldComms; + this.sceneCommunicationPipe = sceneCommunicationPipe; + } + + public void InjectToWorld(ref ArchSystemsWorldBuilder builder, in GlobalPluginArguments arguments) + { + PortableExperienceWorldCommsSystem.InjectToWorld(ref builder, worldComms, sceneCommunicationPipe); + } + } +} diff --git a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs.meta b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs.meta new file mode 100644 index 00000000000..9e2e6b4ee24 --- /dev/null +++ b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 14298238129a049ad9650c281b0b9f30 \ No newline at end of file diff --git a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs new file mode 100644 index 00000000000..12ede87b0a3 --- /dev/null +++ b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs @@ -0,0 +1,85 @@ +using Arch.Core; +using Arch.System; +using Arch.SystemGroups; +using Arch.SystemGroups.DefaultSystemGroups; +using CrdtEcsBridge.JsModulesImplementation.Communications; +using DCL.Multiplayer.Connections.Messaging.Pipe; +using DCL.Multiplayer.Connections.PortableExperiences; +using DCL.Multiplayer.Connections.Rooms.Connective; +using ECS; +using ECS.Abstract; +using ECS.LifeCycle.Components; +using ECS.SceneLifeCycle.SceneDefinition; +using System.Collections.Generic; + +// Lives in the multiplayer-connections namespace (not DCL.PluginSystem.*) on purpose: the +// DCL.PluginSystem.World namespace would shadow Arch.Core.World, and the Arch query source +// generator emits `World` unqualified into this type's namespace. +namespace DCL.Multiplayer.Connections.PortableExperiences +{ + /// + /// Keeps the comms wiring of each running authoritative Portable Experience scene in sync with the loaded + /// experiences. For every authoritative PX scene it (1) ensures a scene-level LiveKit connection exists + /// (so the world-content-server spawns the authoritative server) and (2) registers that room with the shared + /// , so the scene's CRDT is exchanged with the authoritative-server + /// over the PX room instead of the host's current scene room. Worlds whose scene is no longer loaded are + /// unregistered and disconnected. + /// + [UpdateInGroup(typeof(PresentationSystemGroup))] + public partial class PortableExperienceWorldCommsSystem : BaseUnityLoopSystem + { + private readonly PortableExperienceWorldComms worldComms; + private readonly SceneCommunicationPipe sceneCommunicationPipe; + private readonly Dictionary realmDataByEns = new (); + private readonly HashSet liveSceneIds = new (); + + internal PortableExperienceWorldCommsSystem(World world, PortableExperienceWorldComms worldComms, SceneCommunicationPipe sceneCommunicationPipe) : base(world) + { + this.worldComms = worldComms; + this.sceneCommunicationPipe = sceneCommunicationPipe; + } + + protected override void Update(float t) + { + realmDataByEns.Clear(); + liveSceneIds.Clear(); + + // The Portable Experience realm entity carries the world's comms data (keyed by ENS); the scene entity + // carries the authoritativeMultiplayer flag and the sceneId. Collect the realms first, then connect and + // register the scene rooms of authoritative scenes and reconcile the rest away. + CollectRealmDataQuery(World); + ActivateAuthoritativeCommsQuery(World); + + // Stop routing for unloaded scenes before disposing their rooms. + sceneCommunicationPipe.RetainOnlyRooms(liveSceneIds); + worldComms.RetainOnly(liveSceneIds); + } + + [Query] + [None(typeof(DeleteEntityIntention))] + private void CollectRealmData(in PortableExperienceComponent portableExperience, in PortableExperienceRealmComponent realm) + { + realmDataByEns[portableExperience.Ens.ToString()] = realm.RealmData; + } + + [Query] + [None(typeof(PortableExperienceRealmComponent), typeof(DeleteEntityIntention))] + private void ActivateAuthoritativeComms(in PortableExperienceComponent portableExperience, in SceneDefinitionComponent sceneDefinition) + { + if (!sceneDefinition.IsPortableExperience) return; + if (!sceneDefinition.Definition.metadata.authoritativeMultiplayer) return; + + if (!realmDataByEns.TryGetValue(portableExperience.Ens.ToString(), out IRealmData realmData)) return; + + string sceneId = sceneDefinition.Definition.id; + if (string.IsNullOrEmpty(sceneId)) return; + + liveSceneIds.Add(sceneId); + + worldComms.EnsureConnected(sceneId, realmData); + + if (worldComms.TryGetRoom(sceneId, out IConnectiveRoom room, out IMessagePipe roomPipe)) + sceneCommunicationPipe.RegisterSceneRoom(sceneId, room, roomPipe); + } + } +} diff --git a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs.meta b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs.meta new file mode 100644 index 00000000000..3302e7400fb --- /dev/null +++ b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 93aa758c0dedd402890ef0a6d69dba4c \ No newline at end of file diff --git a/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs b/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs index 804e378cd94..eff2deef24c 100644 --- a/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs @@ -1,4 +1,5 @@ using Arch.SystemGroups; +using DCL.Multiplayer.Connections.PortableExperiences; using DCL.Multiplayer.Connections.RoomHubs; using DCL.PluginSystem.World.Dependencies; using DCL.SDKComponents.RealmInfo; @@ -12,11 +13,13 @@ public class RealmInfoPlugin : IDCLWorldPluginWithoutSettings { private readonly IRealmData realmData; private readonly IRoomHub roomHub; + private readonly PortableExperienceWorldComms portableExperienceWorldComms; - public RealmInfoPlugin(IRealmData realmData, IRoomHub roomHub) + public RealmInfoPlugin(IRealmData realmData, IRoomHub roomHub, PortableExperienceWorldComms portableExperienceWorldComms) { this.realmData = realmData; this.roomHub = roomHub; + this.portableExperienceWorldComms = portableExperienceWorldComms; } public void Dispose() @@ -26,7 +29,7 @@ public void Dispose() public void InjectToWorld(ref ArchSystemsWorldBuilder builder, in ECSWorldInstanceSharedDependencies sharedDependencies, in SystemsDependencies systemsDependencies, in PersistentEntities persistentEntities, List finalizeWorldSystems, List sceneIsCurrentListeners) { - WriteRealmInfoSystem.InjectToWorld(ref builder, sharedDependencies.EcsToCRDTWriter, realmData, roomHub, sharedDependencies.SceneData); + WriteRealmInfoSystem.InjectToWorld(ref builder, sharedDependencies.EcsToCRDTWriter, realmData, roomHub, sharedDependencies.SceneData, portableExperienceWorldComms); } } } diff --git a/Explorer/Assets/DCL/SDKComponents/RealmInfo/Tests/WriteRealmInfoSystemShould.cs b/Explorer/Assets/DCL/SDKComponents/RealmInfo/Tests/WriteRealmInfoSystemShould.cs index 288ffb00920..45f00eb8f16 100644 --- a/Explorer/Assets/DCL/SDKComponents/RealmInfo/Tests/WriteRealmInfoSystemShould.cs +++ b/Explorer/Assets/DCL/SDKComponents/RealmInfo/Tests/WriteRealmInfoSystemShould.cs @@ -58,7 +58,14 @@ public void Setup() roomHub.SceneRoom().Returns(gateKeeperSceneRoom); - system = new WriteRealmInfoSystem(world, ecsToCRDTWriter, realmData, roomHub, sceneData); + var portableExperienceWorldComms = new DCL.Multiplayer.Connections.PortableExperiences.PortableExperienceWorldComms( + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For()); + + system = new WriteRealmInfoSystem(world, ecsToCRDTWriter, realmData, roomHub, sceneData, portableExperienceWorldComms); } protected override void OnTearDown() diff --git a/Explorer/Assets/DCL/SDKComponents/RealmInfo/WriteRealmInfoSystem.cs b/Explorer/Assets/DCL/SDKComponents/RealmInfo/WriteRealmInfoSystem.cs index e76c089b153..91b158caa56 100644 --- a/Explorer/Assets/DCL/SDKComponents/RealmInfo/WriteRealmInfoSystem.cs +++ b/Explorer/Assets/DCL/SDKComponents/RealmInfo/WriteRealmInfoSystem.cs @@ -4,6 +4,7 @@ using CrdtEcsBridge.ECSToCRDTWriter; using DCL.ECSComponents; using DCL.Multiplayer.Connections.GateKeeper.Rooms; +using DCL.Multiplayer.Connections.PortableExperiences; using DCL.Multiplayer.Connections.RoomHubs; using DCL.Multiplayer.Connections.Rooms.Connective; using ECS; @@ -22,13 +23,13 @@ public partial class WriteRealmInfoSystem : BaseUnityLoopSystem private bool initialized; - internal WriteRealmInfoSystem(World world, IECSToCRDTWriter ecsToCRDTWriter, IRealmData realmData, IRoomHub roomHub, ISceneData sceneData) + internal WriteRealmInfoSystem(World world, IECSToCRDTWriter ecsToCRDTWriter, IRealmData realmData, IRoomHub roomHub, ISceneData sceneData, PortableExperienceWorldComms portableExperienceWorldComms) : base(world) { this.ecsToCRDTWriter = ecsToCRDTWriter; this.realmData = realmData; - commsRoomInfo = new CommsRoomInfo(roomHub, sceneData); + commsRoomInfo = new CommsRoomInfo(roomHub, sceneData, portableExperienceWorldComms); } public override void Initialize() @@ -71,15 +72,17 @@ public class CommsRoomInfo { private readonly IRoomHub roomHub; private readonly ISceneData sceneData; + private readonly PortableExperienceWorldComms portableExperienceWorldComms; public string IslandSid { get; private set; } public bool IsConnectedSceneRoom { get; private set; } - public CommsRoomInfo(IRoomHub roomHub, ISceneData sceneData) + public CommsRoomInfo(IRoomHub roomHub, ISceneData sceneData, PortableExperienceWorldComms portableExperienceWorldComms) { this.roomHub = roomHub; this.sceneData = sceneData; + this.portableExperienceWorldComms = portableExperienceWorldComms; } /// @@ -89,7 +92,11 @@ public CommsRoomInfo(IRoomHub roomHub, ISceneData sceneData) public bool TryFetchNewInfo() { IGateKeeperSceneRoom sceneRoom = roomHub.SceneRoom(); - bool isConnectedToSceneRoom = sceneRoom.IsSceneConnected(sceneData.SceneEntityDefinition.id); + + // A Portable Experience scene is connected through its own scene room (not the host's current scene room), + // so the SDK only initiates the CRDT handshake when this reflects the PX room's connection. + bool isConnectedToSceneRoom = sceneRoom.IsSceneConnected(sceneData.SceneEntityDefinition.id) + || portableExperienceWorldComms.IsConnected(sceneData.SceneEntityDefinition.id); string room = roomHub.IslandRoom().Info.Sid ?? string.Empty; diff --git a/docs/README.md b/docs/README.md index d25e0a96fa8..ea8a54b2ba6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,6 +39,7 @@ Welcome to the official documentation for Unity Explorer — the Decentraland cl - **[Memory & Resource Unloading](memory-budgeting-and-resource-unloading.md)** — Memory budgeting and cache unloading strategies - **[Multiplayer](multiplayer.md)** — Transport-agnostic multiplayer hub: shared interfaces, movement pipeline, entity/profile tables, SDK propagation, dual-transport wiring - **[LiveKit Networking](livekit-networking.md)** — LiveKit transport: dual-room architecture, messaging pipes, Archipelago/GateKeeper, voice and chat rooms +- **[Authoritative Portable Experiences](authoritative-portable-experiences.md)** — Running an authoritative server (Hammurabi) for a Portable Experience: scene-level comms rooms, the spawn trigger, and the CRDT handshake - **[Pulse](pulse.md)** — Pulse transport: ENet peer transport, peer identity, protocol, feature-flag gating - **[Diagnostics](diagnostics.md)** — ReportHub logging system and Sentry integration diff --git a/docs/authoritative-portable-experiences.md b/docs/authoritative-portable-experiences.md new file mode 100644 index 00000000000..8f052d23fd3 --- /dev/null +++ b/docs/authoritative-portable-experiences.md @@ -0,0 +1,248 @@ +# Authoritative servers for Portable Experiences + +This page explains how a Portable Experience (PX) loaded with `/loadpx` runs +against an authoritative server (codename Hammurabi), and how the Unity client +was wired to make that work. It covers the end-to-end flow across three +services, the client-side components, the constraints that shaped the design, +the three root causes that had to be solved, and the diagnostics used to find +them. + +If you only need the short version: an authoritative PX scene must join its +**own world's scene-level comms room** so the server spawns and the SDK's CRDT +handshake can run over that room — neither of which happens for a PX by default. + +For the transport-level background this builds on, see +[LiveKit Networking](livekit-networking.md), [Multiplayer](multiplayer.md), and +[Scene Runtime](scene-runtime.md). For how PX scenes load, see +[IPFS Realms](ipfs-realms.md). + +## What "authoritative multiplayer" means here + +A scene whose entity definition metadata has `authoritativeMultiplayer == true` +does not run its multiplayer logic peer-to-peer. Instead: + +- A server-side runtime (Hammurabi) runs the scene and owns its CRDT state. +- The client routes the scene's CRDT to a single hardcoded LiveKit participant, + `authoritative-server` (`LiveKitMessagesBroadcaster.AUTH_SERVER_IDENTITY`), + instead of broadcasting to peers + (`CommunicationsControllerAPIImplementationBase.SendBinary`). +- The server is spawned on demand when a user **joins the scene's LiveKit + room**, then joins that room as `authoritative-server` and exchanges CRDT with + every connected client. + +This already works when you teleport directly into an authoritative world. The +work documented here makes it also work when the same world is loaded as a +Portable Experience overlay. + +## End-to-end flow + +Three services cooperate. The client owns only the first column; the other two +live in separate repositories (`comms-gatekeeper` and `sdk-multiplayer-server`). + +``` +unity-explorer (client) comms-gatekeeper sdk-multiplayer-server +───────────────────────── ───────────────── ────────────────────── +PX scene loads (authoritative) + │ + ├─ connect scene room ──► POST /get-scene-adapter + │ worlds// (signed-fetch, sceneId) + │ scenes//comms │ + │ ├─ room name encodes sceneId: + │ │ {prefix}{world}-{sceneId} + │ └─ returns LiveKit token ──► (LiveKit join webhook) + │ room-event-processor: + │ join carries sceneId + │ → spawn Hammurabi + │ ◄──────────────────── authoritative-server joins the same room ◄──┘ + │ + ├─ RealmInfo.isConnectedSceneRoom = true + │ → SDK emits REQ_CRDT_STATE ──► authoritative-server + │ ◄── RES_CRDT_STATE / CRDT ──────┘ (full state, then deltas) + ▼ + scene synced +``` + +The decisive detail is that the join must reach the **scene-level** room +(`worlds//scenes//comms`), whose LiveKit room name encodes the +`sceneId`. The world-level room (`worlds//comms`) carries no `sceneId`, +so the server's `room-event-processor` logs `Join event missing sceneId` and +never spawns. + +## Client architecture + +All client code lives in `DCL.Multiplayer` (the comms primitives), `SceneRuntime` +(the scene comms pipe), and `DCL.Plugins` (the system and plugin). No new +assembly was introduced. + +| Component | Assembly | Responsibility | +|---|---|---| +| `PortableExperienceSceneRoom` | `DCL.Multiplayer` | A `ConnectiveRoom` that connects to `worlds//scenes//comms` with the `sceneId` in the handshake metadata. Always targets the scene endpoint. | +| `PortableExperienceWorldComms` | `DCL.Multiplayer` | Owns one `PortableExperienceSceneRoom` plus a `MessagePipe` per authoritative PX scene, keyed by sceneId. Exposes `EnsureConnected`, `TryGetRoom`, `IsConnected`, `RetainOnly`. | +| `SceneCommunicationPipe` (extended) | `SceneRuntime` | Gained multi-room routing: `RegisterSceneRoom` / `RetainOnlyRooms`. Outbound, inbound, and `IsSceneConnected` for a registered sceneId route to that scene's PX room instead of the host's current scene room. The host-room path is unchanged. | +| `PortableExperienceWorldCommsSystem` + `PortableExperienceCommsPlugin` | `DCL.Plugins` | Global-world system that, each frame, connects the room for every authoritative PX scene, registers the room with the pipe, and reconciles rooms whose scene unloaded. | +| `WriteRealmInfoSystem` (extended) | `DCL.Plugins` | Reports `RealmInfo.isConnectedSceneRoom == true` when the PX room is connected (not only the host scene room). | + +### Wiring and ownership + +The shared `SceneCommunicationPipe` is created once in `CommsContainer` (hoisted +out of `SceneSharedContainer` so both the scene factory and the PX plugin share +one instance, avoiding an `ObjectProxy`). `PortableExperienceWorldComms` is also +created in `CommsContainer`, which already has the LiveKit pools the per-room +message pipes need. + +The `DCL.Plugins` system is the only place that legally sees both sides: the +rooms (in `DCL.Multiplayer`) and the pipe (in `SceneRuntime`). The dependency +direction is `SceneRuntime → DCL.Multiplayer`, never the reverse, so the service +never references the pipe — the system registers rooms with the pipe instead. + +## Constraints that shaped the design + +These are the non-obvious rules that ruled out simpler approaches. + +- **The comms layer is single-realm.** `RoomHub` holds exactly four fixed rooms + (Island, Scene, Chat, Voice) tied to the player's current realm. A PX is an + overlay that does not change the current realm, so its `RealmData.CommsAdapter` + is captured on `PortableExperienceRealmComponent` but never fed into `RoomHub`. + A PX therefore needs its **own** room, modeled per-scene rather than added to + `RoomHub`. +- **PX scenes are excluded from the by-parcel index.** `ControlSceneUpdateLoopSystem` + stores PX scenes in `portableExperienceScenesByUrn`, not `scenesByParcels`, so + `IScenesCache.TryGetByParcel` never returns a PX. This is why remote players + are never propagated into a PX world and why the host scene room is never + connected to a PX scene. +- **A PX realm reports `SingleScene == true`.** `ECSPortableExperiencesController` + builds the PX `RealmData` with `WorldManifest.Empty`, so `RealmData.SingleScene` + is `true`. `GateKeeperSceneRoomOptions.GetAdapterURL` only uses the world + scene endpoint when `IsWorld() && !SingleScene`, so reusing `GateKeeperSceneRoom` + for a PX would hit the wrong (world-level) endpoint. `PortableExperienceSceneRoom` + bypasses this by always building the scene-level URL. +- **Assembly direction.** Systems must compile into `DCL.Plugins` (they reference + many assemblies). `SceneRuntime` may reference `DCL.Multiplayer`, never the + reverse. These rules dictated where each piece lives. + + +> [!NOTE] +> A namespace gotcha: code under `DCL.PluginSystem.*` cannot use the unqualified +> `World` type, because the `DCL.PluginSystem.World` namespace shadows +> `Arch.Core.World` and the Arch query source generator emits `World` unqualified. +> `PortableExperienceWorldCommsSystem` therefore lives in the +> `DCL.Multiplayer.Connections.PortableExperiences` namespace even though its +> file sits under `PluginSystem/Global/`. + +## The three problems we solved + +Getting a PX to talk to its authoritative server required fixing three distinct +gaps, each surfaced by a different symptom. + +### 1. No spawn trigger + +By default a PX never joins any scene comms room, so the server side never sees +a user and never spawns. The fix is `PortableExperienceWorldComms` plus its +driving system: when an authoritative PX scene is running, the client opens a +data-only LiveKit connection to that world's comms. + +### 2. Wrong room — the join carried no sceneId + +The first implementation connected to the **world-level** adapter +(`worlds//comms`). The server received the join but logged +`Join event missing sceneId` and skipped the spawn, because the world-level +LiveKit room name carries no `sceneId`. + +The fix is `PortableExperienceSceneRoom`, which connects to the **scene-level** +endpoint (`worlds//scenes//comms`) with the `sceneId` in the +signed handshake metadata. `comms-gatekeeper` then names the room +`{worldRoomPrefix}{world}-{sceneId}`, and its `getRoomMetadataFromRoomName` +parses the `sceneId` back out, so the server's join event carries it and +Hammurabi spawns. + +### 3. No CRDT handshake — the SDK never sent `REQ_CRDT_STATE` + +With the room connected and the server spawned, no CRDT flowed in either +direction. The Unity comms plumbing was correct (`SendBinary` reached the pipe), +but the SDK runtime handed it an **empty** outgoing batch every tick. + +The cause is in `js-sdk-toolchain`. In +`packages/@dcl/sdk/src/network/message-bus-sync.ts`, the function that emits the +bootstrap `REQ_CRDT_STATE` is gated on the scene's `RealmInfo`: + +```ts +if (!RealmInfo.getOrNull(engine.RootEntity)?.isConnectedSceneRoom) return +binaryMessageBus.emit(CommsMessage.REQ_CRDT_STATE, new Uint8Array()) +// ...and requestState() itself is triggered by: +RealmInfo.onChange(... if (value?.isConnectedSceneRoom && !stateIsSyncronized) requestState()) +``` + +The client writes `RealmInfo` through `WriteRealmInfoSystem`, which computed +`isConnectedSceneRoom` from the **host** scene room (`roomHub.SceneRoom()`) — +never connected to a PX scene, so always `false`. The SDK therefore never +requested state. + +The fix makes `WriteRealmInfoSystem` also report `true` when the PX room is +connected, via `PortableExperienceWorldComms.IsConnected(sceneId)`. When the PX +room finishes connecting, the flag flips, `RealmInfo.onChange` fires, +`requestState()` runs, and `REQ_CRDT_STATE` finally reaches `authoritative-server`, +which responds with `RES_CRDT_STATE` and ongoing CRDT deltas. + +## Message types and the handshake + +The binary message bus uses a one-byte message type that must stay aligned with +the SDK (`CommunicationsControllerAPIImplementationBase.CommsMessageType`): + +| Value | Type | Direction | +|---|---|---| +| 1 | `CRDT` | Both — incremental state | +| 2 | `REQ_CRDT_STATE` | Client → server — request full state on connect | +| 3 | `RES_CRDT_STATE` | Server → client — full state response | + +`REQ_CRDT_STATE` is sent with `DELIVERY_ASSERTED` and the SDK retries it for a +few seconds; `CRDT` deltas use `DROP_IF_NOT_CONNECTED`. + +## Diagnostics and gotchas + +These cost real time during development; keep them in mind when debugging comms. + +- **Use `ReportHub.LogProductionInfo` for must-see logs.** Plain `Debug.Log` + /`Debug.LogWarning` is swallowed or redirected by the project's custom log + handler, and category logs (for example `COMMS_SCENE_HANDLER`) are filtered by + the severity matrix. `LogProductionInfo` renders as the always-visible + `[ALWAYS]` lines. See [Diagnostics](diagnostics.md) and + [Override Debug Log Matrix](override-debug-log-matrix.md). +- **The Console error count is the source of truth, not `Editor.log`.** + `Editor.log` is append-only across every recompile, so grepping it for + `error CS` mixes results from many transient builds (the Editor auto-compiles + after every saved file). Check the live Console count instead. +- **Script changes do not compile during Play mode.** Unity defers recompilation + until you exit Play. A `⌘R` refresh while playing reports `compile time=0`. +- **`InteriorRoom.DataPipe` is a stable proxy.** A `MessagePipe` built over a + room's data pipe before the room connects keeps receiving across LiveKit room + swaps, because `InteriorDataPipe.Assign` re-points to each new underlying room. + This is why the PX message pipe can be built eagerly. + +### Verify a PX is talking to its authoritative server + +1. Confirm the world is reachable: open + `https://worlds-content-server.decentraland.org/world/.dcl.eth/about` + and check `configurations.scenesUrn` is non-empty. +2. With `explorer-alfa-portable-experiences-chat-commands` enabled, run + `/loadpx ` once you are in-world. +3. In `sdk-multiplayer-server` logs, confirm `Spawning process for + authoritativeMultiplayer scene` with the scene's entity hash. +4. In the Unity Console, confirm the scene room connects, the SDK emits a + `REQ_CRDT_STATE` (`firstByte=2`), and the client receives `RES_CRDT_STATE` + from the server. + + +> [!NOTE] +> Temporary `[PX-COMMS]` diagnostic logs were added to `SceneCommunicationPipe`, +> `PortableExperienceWorldComms`, and `CommunicationsControllerAPIImplementationBase` +> to trace the connect → register → send → receive chain. Remove them once the +> feature is verified. + +## See also + +- [LiveKit Networking](livekit-networking.md) — rooms, message pipes, GateKeeper +- [Multiplayer](multiplayer.md) — transport-neutral hub and SDK propagation +- [Scene Runtime](scene-runtime.md) — CRDT bridge and the comms controller +- [IPFS Realms](ipfs-realms.md) — realms, worlds, and `RealmData` +- [Architecture Overview](architecture-overview.md) — containers and the + "deferred dependencies" rules that shaped the wiring From e481b0d8c01e6e52307f6622d021c8d5650d40b3 Mon Sep 17 00:00:00 2001 From: Gabriel Diaz Date: Thu, 25 Jun 2026 12:13:44 +0200 Subject: [PATCH 2/3] refactor: own authoritative PX comms in the scene lifecycle Addresses Mikhail's review: the authoritative Portable Experience scene room was driven by a global, stateful, per-frame reconciler that re-derived state the explicit load/unload systems already own. Move ownership into the scene's lifecycle instead. - PortableExperienceSceneFacade now owns the scene room + its message pipe: connected as an awaited step of LoadSceneSystemLogicBase.FlowAsync (the join triggers the server spawn, so the PX is not loaded until connected) and torn down (routing removed, room stopped/disposed) in DisposeInternal on unload. - PortableExperienceRoomFactory: stateless builder of the room + pipe (replaces the stateful PortableExperienceWorldComms keyed dictionary). - Delete PortableExperienceWorldCommsSystem + PortableExperienceCommsPlugin and their wiring; the per-frame query and reconciliation are gone. - SceneCommunicationPipe: RetainOnlyRooms(collection) -> explicit RemoveSceneRoom(id); subscribe inside the lock on register and unsubscribe on remove. Add IMessagePipe.Unsubscribe to make teardown self-contained. - WriteRealmInfoSystem: read isConnectedSceneRoom from the scene's own facade via IScenesCache (not an injected intermediary); branch PX vs non-PX so the host scene-room check is omitted for a PX; fix the nullability violation. - Drop the unused WithThroughputMeasure buffers. - The PX RealmData reaches the load flow on PortableExperienceComponent, read into GetSceneFacadeIntention by the radius system. --- .../CrdtEcsBridge/ISceneCommunicationPipe.cs | 17 +- .../Communications/SceneCommunicationPipe.cs | 30 ++-- ...cationControllerAPIImplementationShould.cs | 4 + .../Global/Dynamic/CommsContainer.cs | 14 -- .../Global/Dynamic/DynamicWorldContainer.cs | 4 +- .../ECSPortableExperiencesController.cs | 2 +- ...solveSceneStateByIncreasingRadiusSystem.cs | 10 +- .../Components/PortableExperienceComponent.cs | 8 +- .../Components/GetSceneFacadeIntention.cs | 9 +- .../Systems/LoadSceneSystemLogicBase.cs | 13 ++ .../PortableExperienceSceneFacade.cs | 74 +++++++- .../Infrastructure/SceneRunner/SceneFacade.cs | 2 +- .../SceneRunner/SceneFactory.cs | 8 +- .../SceneRuntime/Tests/CommsApiWrapShould.cs | 4 + .../Messaging/Pipe/IMessagePipe.cs | 7 + .../Messaging/Pipe/LogMessagePipe.cs | 8 + .../Connections/Messaging/Pipe/MessagePipe.cs | 8 + .../PortableExperienceRoomFactory.cs | 46 +++++ ... => PortableExperienceRoomFactory.cs.meta} | 0 .../PortableExperienceWorldComms.cs | 162 ------------------ .../Global/PortableExperienceComms.meta | 8 - .../PortableExperienceCommsPlugin.cs | 29 ---- .../PortableExperienceCommsPlugin.cs.meta | 2 - .../PortableExperienceWorldCommsSystem.cs | 85 --------- ...PortableExperienceWorldCommsSystem.cs.meta | 2 - .../DCL/PluginSystem/World/RealmInfoPlugin.cs | 10 +- .../Tests/WriteRealmInfoSystemShould.cs | 32 +++- .../RealmInfo/WriteRealmInfoSystem.cs | 36 ++-- docs/authoritative-portable-experiences.md | 80 +++++---- 29 files changed, 314 insertions(+), 400 deletions(-) create mode 100644 Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceRoomFactory.cs rename Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/{PortableExperienceWorldComms.cs.meta => PortableExperienceRoomFactory.cs.meta} (100%) delete mode 100644 Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceWorldComms.cs delete mode 100644 Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms.meta delete mode 100644 Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs delete mode 100644 Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs.meta delete mode 100644 Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs delete mode 100644 Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs.meta diff --git a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/ISceneCommunicationPipe.cs b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/ISceneCommunicationPipe.cs index 6aef631d34e..3b80564ff49 100644 --- a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/ISceneCommunicationPipe.cs +++ b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/ISceneCommunicationPipe.cs @@ -1,4 +1,6 @@ -using System; +using DCL.Multiplayer.Connections.Messaging.Pipe; +using DCL.Multiplayer.Connections.Rooms.Connective; +using System; using System.Threading; namespace CrdtEcsBridge.JsModulesImplementation.Communications @@ -31,6 +33,19 @@ public enum ConnectivityAssertiveness void RemoveSceneMessageHandler(string sceneId, MsgType msgType, SceneMessageHandler onSceneMessage); + /// + /// Routes the given scene's comms to a dedicated room (its own message pipe) instead of the host's + /// current scene room. Idempotent. The caller owns the room and pipe lifecycle; + /// only stops routing and removes this pipe's inbound subscription. + /// + void RegisterSceneRoom(string sceneId, IConnectiveRoom room, IMessagePipe roomPipe); + + /// + /// Stops routing for a previously registered scene room and unsubscribes from its inbound pipe. Disposing + /// the underlying room/pipe is the caller's responsibility. No-op if the scene was never registered. + /// + void RemoveSceneRoom(string sceneId); + void SendMessage(ReadOnlySpan message, string sceneId, ConnectivityAssertiveness assertiveness, CancellationToken ct, string? specialRecipient = null); readonly ref struct DecodedMessage diff --git a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Communications/SceneCommunicationPipe.cs b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Communications/SceneCommunicationPipe.cs index 5386cc8dd08..d6c2e7937fb 100644 --- a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Communications/SceneCommunicationPipe.cs +++ b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Communications/SceneCommunicationPipe.cs @@ -28,7 +28,6 @@ public class SceneCommunicationPipe : ISceneCommunicationPipe // Additional per-scene rooms (e.g. authoritative Portable Experience scene rooms) keyed by sceneId. When a // sceneId has an entry here, its comms are routed to/from that room instead of the host's current scene room. private readonly Dictionary extraRoomsBySceneId = new (); - private readonly List staleSceneIdsBuffer = new (); public SceneCommunicationPipe(IMessagePipesHub messagePipesHub, IGateKeeperSceneRoom sceneRoom) { @@ -39,38 +38,35 @@ public SceneCommunicationPipe(IMessagePipesHub messagePipesHub, IGateKeeperScene /// /// Routes the given scene's comms to a dedicated room (its own message pipe) instead of the host's current - /// scene room. Idempotent. The caller owns the room and pipe lifecycle; only - /// stops routing. + /// scene room. Idempotent. The caller owns the room and pipe lifecycle; only + /// stops routing and unsubscribes from the pipe. /// public void RegisterSceneRoom(string sceneId, IConnectiveRoom room, IMessagePipe roomPipe) { lock (extraRoomsBySceneId) { if (extraRoomsBySceneId.ContainsKey(sceneId)) return; + + // Subscribe before publishing the entry so the inbound subscription is active the moment SendMessage + // (called from the scene thread) can find the entry and route outbound to this pipe. The pipe is + // freshly created for and exclusively owned by this scene, so the single subscription MessagePipe + // allows per message type always succeeds here. + roomPipe.Subscribe(Packet.MessageOneofCase.Scene, InvokeSubscriber, IMessagePipe.ThreadStrict.ORIGIN_THREAD); extraRoomsBySceneId[sceneId] = new RoomChannel(room, roomPipe); } - - roomPipe.Subscribe(Packet.MessageOneofCase.Scene, InvokeSubscriber, IMessagePipe.ThreadStrict.ORIGIN_THREAD); } /// - /// Stops routing for any registered scene room whose sceneId is no longer present in - /// . Disposing the underlying room/pipe is the caller's responsibility. + /// Stops routing for a previously registered scene room and unsubscribes + /// from its pipe. Disposing the underlying room/pipe is the caller's responsibility. No-op if not registered. /// - public void RetainOnlyRooms(ICollection liveSceneIds) + public void RemoveSceneRoom(string sceneId) { lock (extraRoomsBySceneId) { - if (extraRoomsBySceneId.Count == 0) return; - - staleSceneIdsBuffer.Clear(); - - foreach (string sceneId in extraRoomsBySceneId.Keys) - if (!liveSceneIds.Contains(sceneId)) - staleSceneIdsBuffer.Add(sceneId); + if (!extraRoomsBySceneId.Remove(sceneId, out RoomChannel channel)) return; - foreach (string sceneId in staleSceneIdsBuffer) - extraRoomsBySceneId.Remove(sceneId); + channel.Pipe.Unsubscribe(Packet.MessageOneofCase.Scene, InvokeSubscriber); } } diff --git a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Tests/CommunicationControllerAPIImplementationShould.cs b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Tests/CommunicationControllerAPIImplementationShould.cs index 74441bbc29c..5122b736786 100644 --- a/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Tests/CommunicationControllerAPIImplementationShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/Tests/CommunicationControllerAPIImplementationShould.cs @@ -364,6 +364,10 @@ public void SendMessage(ReadOnlySpan message, string sceneId, ISceneCommun { sendMessageCalls.Add(message.ToArray()); } + + public void RegisterSceneRoom(string sceneId, DCL.Multiplayer.Connections.Rooms.Connective.IConnectiveRoom room, DCL.Multiplayer.Connections.Messaging.Pipe.IMessagePipe roomPipe) { } + + public void RemoveSceneRoom(string sceneId) { } } } } diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs index 42be78edf82..582ccf7420f 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/CommsContainer.cs @@ -13,7 +13,6 @@ using DCL.Multiplayer.Connections.GateKeeper.Rooms.Options; using DCL.Multiplayer.Connections.Messaging.Hubs; using DCL.Multiplayer.Connections.Pools; -using DCL.Multiplayer.Connections.PortableExperiences; using DCL.Multiplayer.Connections.RoomHubs; using DCL.Multiplayer.Connections.Rooms.Connective; using DCL.Multiplayer.Connections.Rooms.Status; @@ -70,8 +69,6 @@ public class CommsContainer : IDisposable public CurrentSceneInfo CurrentSceneInfo { get; } - public PortableExperienceWorldComms PortableExperienceWorldComms { get; } - public SceneCommunicationPipe SceneCommunicationPipe { get; } private CommsContainer( @@ -90,7 +87,6 @@ private CommsContainer( IRemoteMetadata remoteMetadata, IHealthCheck livekitHealthCheck, CurrentSceneInfo currentSceneInfo, - PortableExperienceWorldComms portableExperienceWorldComms, SceneCommunicationPipe sceneCommunicationPipe) { this.archipelagoIslandRoom = archipelagoIslandRoom; @@ -108,7 +104,6 @@ private CommsContainer( RemoteMetadata = remoteMetadata; LivekitHealthCheck = livekitHealthCheck; CurrentSceneInfo = currentSceneInfo; - PortableExperienceWorldComms = portableExperienceWorldComms; SceneCommunicationPipe = sceneCommunicationPipe; } @@ -211,13 +206,6 @@ public static CommsContainer Create( ? livekitHealthCheck.WithFailAnalytics(bootstrapContainer.Analytics.Controller) : livekitHealthCheck; - var portableExperienceWorldComms = new PortableExperienceWorldComms( - staticContainer.WebRequestsContainer.WebRequestController, - identityCache, - bootstrapContainer.DecentralandUrlsSource, - MultiPoolFactory(), - new ArrayMemoryPool(ArrayPool.Shared!)); - var sceneCommunicationPipe = new SceneCommunicationPipe(messagePipesHub, roomHub.SceneRoom()); return new CommsContainer( @@ -236,7 +224,6 @@ public static CommsContainer Create( remoteMetadata, livekitHealthCheck, new CurrentSceneInfo(), - portableExperienceWorldComms, sceneCommunicationPipe); } @@ -280,7 +267,6 @@ public ConnectionStatusPanelPlugin CreateConnectionStatusPanelPlugin(IAssetsProv public void Dispose() { MessagePipesHub.Dispose(); - PortableExperienceWorldComms.Dispose(); } private static IMultiPool MultiPoolFactory() => diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs index 7dd447097fe..8b2f8bcb725 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs @@ -51,7 +51,6 @@ using CrdtEcsBridge.JsModulesImplementation.Communications; using DCL.PluginSystem; using DCL.PluginSystem.Global; -using DCL.PluginSystem.Global.PortableExperienceComms; using DCL.PluginSystem.SmartWearables; using DCL.PluginSystem.World; using DCL.PrivateWorlds; @@ -446,7 +445,7 @@ await MapRendererContainer { new AvatarAttachPlugin(globalWorld, staticContainer.MainPlayerAvatarBaseProxy, staticContainer.ComponentsContainer.ComponentPoolsRegistry, commsContainer.EntityParticipantTable, staticContainer.CharacterContainer.Transform), new SceneMaskedEmotePlugin(globalWorld, playerEntity, staticContainer.MainPlayerAvatarBaseProxy, staticContainer.EmotesContainer.EmotePlayer, staticContainer.EmoteStorage, multiplayerEmotesMessageBus), - new RealmInfoPlugin(staticContainer.RealmData, commsContainer.RoomHub, commsContainer.PortableExperienceWorldComms), + new RealmInfoPlugin(staticContainer.RealmData, commsContainer.RoomHub, staticContainer.ScenesCache), }; var characterPreviewEventBus = new CharacterPreviewEventBus(); @@ -525,7 +524,6 @@ await MapRendererContainer new AdaptivePerformancePlugin(staticContainer.Profiler, staticContainer.LoadingStatus), new LightSourceDebugPlugin(staticContainer.DebugContainerBuilder, globalWorld), commsContainer.CreateMultiplayerPlugin(staticContainer, assetsProvisioner, debugBuilder, multiplayerContainer), - new PortableExperienceCommsPlugin(commsContainer.PortableExperienceWorldComms, commsContainer.SceneCommunicationPipe), staticContainer.ProfilesContainer.CreatePlugin(), new WorldInfoPlugin(realmNavigatorContainer.WorldInfoHub, debugBuilder, chatContainer.ChatHistory), new CharacterMotionPlugin(staticContainer.RealmData, staticContainer.CharacterContainer.CharacterObject, debugBuilder, staticContainer.ComponentsContainer.ComponentPoolsRegistry, diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/PortableExperiences/ECSPortableExperiencesController.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/PortableExperiences/ECSPortableExperiencesController.cs index eaba16549d8..67da6fa9b1d 100644 --- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/PortableExperiences/ECSPortableExperiencesController.cs +++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/PortableExperiences/ECSPortableExperiencesController.cs @@ -129,7 +129,7 @@ public ECSPortableExperiencesController( ISceneFacade parentScene = scenesCache.Scenes.FirstOrDefault(s => s.SceneStateProvider.IsCurrent); string parentSceneName = parentScene != null ? parentScene.Info.Name : "main"; Entity portableExperienceEntity = world.Create(); - world.Add(portableExperienceEntity, new PortableExperienceRealmComponent(realmData, parentSceneName, isGlobalPortableExperience), new PortableExperienceComponent(ens)); + world.Add(portableExperienceEntity, new PortableExperienceRealmComponent(realmData, parentSceneName, isGlobalPortableExperience), new PortableExperienceComponent(ens, realmData)); world.Add(portableExperienceEntity, new PortableExperienceMetadata { Type = isGlobalPortableExperience ? PortableExperienceType.GLOBAL : PortableExperienceType.LOCAL, diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/IncreasingRadius/ResolveSceneStateByIncreasingRadiusSystem.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/IncreasingRadius/ResolveSceneStateByIncreasingRadiusSystem.cs index ba3cd3eb237..800713c753b 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/IncreasingRadius/ResolveSceneStateByIncreasingRadiusSystem.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/IncreasingRadius/ResolveSceneStateByIncreasingRadiusSystem.cs @@ -112,9 +112,17 @@ private void AddNewSceneDefinitionToList(in Entity entity, in PartitionComponent { if (sceneDefinitionComponent.IsPortableExperience) { + // Authoritative-multiplayer PX scenes carry their world's realm so the load flow can join the + // scene-comms room (which spawns the authoritative server). The realm rides on the entity's + // PortableExperienceComponent; null for ordinary PX leaves the load flow untouched. + IRealmData? portableExperienceRealm = sceneDefinitionComponent.Definition.metadata.authoritativeMultiplayer + && World.TryGet(entity, out PortableExperienceComponent portableExperience) + ? portableExperience.RealmData + : null; + //Portable experiences shouldnt be analyzed. Create straight away World.Add(entity, AssetPromise.Create(World, - new GetSceneFacadeIntention(sceneDefinitionComponent, issDescriptor), partitionComponent), SceneLoadingState.CreatePortableExperience()); + new GetSceneFacadeIntention(sceneDefinitionComponent, issDescriptor, portableExperienceRealm), partitionComponent), SceneLoadingState.CreatePortableExperience()); } else { diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneDefinition/Components/PortableExperienceComponent.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneDefinition/Components/PortableExperienceComponent.cs index 5743642c4f3..f6421c3d5ef 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneDefinition/Components/PortableExperienceComponent.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneDefinition/Components/PortableExperienceComponent.cs @@ -4,15 +4,19 @@ namespace ECS.SceneLifeCycle.SceneDefinition { /// /// A component used to indicate which scenes are PX and filter them from certain queries. - /// Also contains an ID to help filter which entities belong to certain PX + /// Also contains an ID to help filter which entities belong to certain PX. + /// Carries the PX world's (name, comms secret) so the scene loading flow + /// can establish the authoritative scene-comms room for the experience. /// public struct PortableExperienceComponent { public readonly ENS Ens; + public readonly IRealmData RealmData; - public PortableExperienceComponent(ENS ens) + public PortableExperienceComponent(ENS ens, IRealmData realmData) { Ens = ens; + RealmData = realmData; } }; } diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Components/GetSceneFacadeIntention.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Components/GetSceneFacadeIntention.cs index 1883289f1b1..5a647b8321f 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Components/GetSceneFacadeIntention.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Components/GetSceneFacadeIntention.cs @@ -28,10 +28,17 @@ public struct GetSceneFacadeIntention : ILoadingIntention, IEquatable public readonly ISSDescriptor ISSDescriptor; - public GetSceneFacadeIntention(SceneDefinitionComponent definitionComponent, ISSDescriptor issDescriptor) + /// + /// Set only for authoritative-multiplayer Portable Experiences: the PX world's realm data, used by the + /// scene loading flow to establish the scene-comms room. Null for ordinary scenes and non-authoritative PX. + /// + public readonly IRealmData? PortableExperienceRealm; + + public GetSceneFacadeIntention(SceneDefinitionComponent definitionComponent, ISSDescriptor issDescriptor, IRealmData? portableExperienceRealm = null) { DefinitionComponent = definitionComponent; ISSDescriptor = issDescriptor; + PortableExperienceRealm = portableExperienceRealm; // URL = EntityId just for identification, it is used by LoadSystemBase, it won't be used as a URL CommonArguments = new CommonLoadingArguments(definitionComponent.IpfsPath.EntityId); diff --git a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Systems/LoadSceneSystemLogicBase.cs b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Systems/LoadSceneSystemLogicBase.cs index 26a671c0945..2b7ea780dd7 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Systems/LoadSceneSystemLogicBase.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneLifeCycle/SceneFacade/Systems/LoadSceneSystemLogicBase.cs @@ -57,6 +57,19 @@ public async UniTask FlowAsync(World world, ISceneFactory sceneFac await UniTask.SwitchToMainThread(); + // Authoritative-multiplayer Portable Experiences must join their world scene room before the scene is + // considered loaded: the join is what makes the world-content-server spawn the authoritative server. + // The room is owned and torn down by the facade; a failed connect fails the load. + if (intention.PortableExperienceRealm != null && sceneFacade is PortableExperienceSceneFacade portableExperienceScene) + { + try { await portableExperienceScene.StartAuthoritativeRoomAsync(intention.PortableExperienceRealm, ct); } + catch + { + await sceneFacade.DisposeAsync(); + throw; + } + } + sceneFacade.Initialize(); ReportHub.LogProductionInfo($"Loading scene {(sceneFacade.SceneData.IsPortableExperience() ? "(PX)" : "")} '{definition.GetLogSceneName()}' (sdk version: '{sceneData.GetSDKVersion()}') ended"); return sceneFacade; diff --git a/Explorer/Assets/DCL/Infrastructure/SceneRunner/PortableExperienceSceneFacade.cs b/Explorer/Assets/DCL/Infrastructure/SceneRunner/PortableExperienceSceneFacade.cs index 8e2f0e9e702..abb3c561d54 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneRunner/PortableExperienceSceneFacade.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneRunner/PortableExperienceSceneFacade.cs @@ -1,14 +1,84 @@ -using SceneRunner.Scene; +using CrdtEcsBridge.JsModulesImplementation.Communications; +using Cysharp.Threading.Tasks; +using DCL.Diagnostics; +using DCL.LiveKit.Public; +using DCL.Multiplayer.Connections.Messaging.Pipe; +using DCL.Multiplayer.Connections.PortableExperiences; +using DCL.Multiplayer.Connections.Rooms.Connective; +using ECS; +using SceneRunner.Scene; +using System; +using System.Threading; namespace SceneRunner { public class PortableExperienceSceneFacade : SceneFacade { + private readonly PortableExperienceRoomFactory roomFactory; + private readonly ISceneCommunicationPipe sceneCommunicationPipe; + + private IConnectiveRoom? authoritativeRoom; + private IMessagePipe? authoritativeRoomPipe; + private string authoritativeSceneId = string.Empty; + + /// + /// True when this experience's authoritative scene room exists and its LiveKit connection is established. + /// Drives RealmInfo.isConnectedSceneRoom so the SDK initiates the CRDT handshake for the PX. + /// + public bool IsConnectedSceneRoom => + authoritativeRoom != null + && authoritativeRoom.CurrentState() == IConnectiveRoom.State.Running + && authoritativeRoom.Room().Info.ConnectionState == LKConnectionState.ConnConnected; + public PortableExperienceSceneFacade( ISceneData sceneData, - SceneInstanceDependencies.WithRuntimeAndJsAPIBase deps) : base(sceneData, deps) + SceneInstanceDependencies.WithRuntimeAndJsAPIBase deps, + PortableExperienceRoomFactory roomFactory, + ISceneCommunicationPipe sceneCommunicationPipe) : base(sceneData, deps) { + this.roomFactory = roomFactory; + this.sceneCommunicationPipe = sceneCommunicationPipe; SetIsCurrent(true); } + + /// + /// Connects this PX to its world scene room (worlds/{realm}/scenes/{sceneId}/comms) and routes the + /// scene's CRDT through it. Awaited as part of the scene loading flow: joining the room is what makes the + /// world-content-server spawn the authoritative server, so the PX is not considered loaded until connected. + /// + public async UniTask StartAuthoritativeRoomAsync(IRealmData portableExperienceRealm, CancellationToken ct) + { + string? id = SceneData.SceneEntityDefinition.id; + if (string.IsNullOrEmpty(id)) return; + + authoritativeSceneId = id; + (authoritativeRoom, authoritativeRoomPipe) = roomFactory.Create(portableExperienceRealm, authoritativeSceneId); + sceneCommunicationPipe.RegisterSceneRoom(authoritativeSceneId, authoritativeRoom, authoritativeRoomPipe); + + ReportHub.Log(ReportCategory.COMMS_SCENE_HANDLER, $"Portable Experience comms: connecting scene '{authoritativeSceneId}' of world '{portableExperienceRealm.RealmName}'"); + await authoritativeRoom.StartAsync().AttachExternalCancellation(ct); + } + + protected override void DisposeInternal() + { + if (authoritativeRoom != null) + { + sceneCommunicationPipe.RemoveSceneRoom(authoritativeSceneId); + authoritativeRoomPipe?.Dispose(); + StopAndDisposeAsync(authoritativeRoom).Forget(); + authoritativeRoom = null; + authoritativeRoomPipe = null; + } + + base.DisposeInternal(); + } + + private static async UniTaskVoid StopAndDisposeAsync(IConnectiveRoom room) + { + try { await room.StopIfNotAsync(); } + catch (OperationCanceledException) { } + catch (Exception e) { ReportHub.LogException(e, new ReportData(ReportCategory.COMMS_SCENE_HANDLER)); } + finally { room.Dispose(); } + } } } diff --git a/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFacade.cs b/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFacade.cs index 9163f64eb33..a3961fd2404 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFacade.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFacade.cs @@ -118,7 +118,7 @@ public async UniTask DisposeAsync() SceneStateProvider.State.Set(SceneState.Disposed); } - private void DisposeInternal() => + protected virtual void DisposeInternal() => deps.Dispose(); public void SetTargetFPS(int fps) diff --git a/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFactory.cs b/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFactory.cs index 57b80df8ba6..67b9bf250c1 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFactory.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneFactory.cs @@ -12,6 +12,7 @@ using DCL.Interaction.Utility; using DCL.Ipfs; using DCL.Multiplayer.Connections.DecentralandUrls; +using DCL.Multiplayer.Connections.PortableExperiences; using DCL.Multiplayer.Connections.RoomHubs; using DCL.Multiplayer.Profiles.Poses; using DCL.Profiles; @@ -69,6 +70,7 @@ public class SceneFactory : ISceneFactory private readonly DecentralandEnvironment dclEnvironment; private readonly ISystemClipboard systemClipboard; private readonly string installSource; + private readonly PortableExperienceRoomFactory portableExperienceRoomFactory; private IGlobalWorldActions globalWorldActions = null!; @@ -119,6 +121,8 @@ public SceneFactory( this.remoteMetadata = remoteMetadata; this.dclEnvironment = dclEnvironment; this.installSource = installSource; + + portableExperienceRoomFactory = new PortableExperienceRoomFactory(webRequestController, identityCache, decentralandUrlsSource); } public async UniTask CreateSceneFromFileAsync(string jsCodeUrl, IPartitionComponent partitionProvider, CancellationToken ct, string id = "") @@ -277,7 +281,9 @@ private async UniTask CreateSceneAsync(ISceneData sceneData, IJsAp { return new PortableExperienceSceneFacade( sceneData, - runtimeDeps + runtimeDeps, + portableExperienceRoomFactory, + messagePipesHub ); } diff --git a/Explorer/Assets/DCL/Infrastructure/SceneRuntime/Tests/CommsApiWrapShould.cs b/Explorer/Assets/DCL/Infrastructure/SceneRuntime/Tests/CommsApiWrapShould.cs index 80e87a344cf..4f1ab447154 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneRuntime/Tests/CommsApiWrapShould.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneRuntime/Tests/CommsApiWrapShould.cs @@ -279,6 +279,10 @@ public void SendMessage(ReadOnlySpan message, string sceneId, ISceneCommun { sendMessageCalls.Add(message.ToArray()); } + + public void RegisterSceneRoom(string sceneId, DCL.Multiplayer.Connections.Rooms.Connective.IConnectiveRoom room, DCL.Multiplayer.Connections.Messaging.Pipe.IMessagePipe roomPipe) { } + + public void RemoveSceneRoom(string sceneId) { } } } } diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/IMessagePipe.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/IMessagePipe.cs index 2d0bea26b07..bd6e9409584 100644 --- a/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/IMessagePipe.cs +++ b/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/IMessagePipe.cs @@ -23,6 +23,12 @@ enum ThreadStrict void Subscribe(Packet.MessageOneofCase ofCase, Action> onMessageReceived, ThreadStrict threadStrict = ThreadStrict.MAIN_THREAD_ONLY) where T: class, IMessage, new(); + /// + /// Removes the subscription for the given message type. Only one subscriber per type is allowed + /// (see ), so the type uniquely identifies the subscription to remove. + /// + void Unsubscribe(Packet.MessageOneofCase ofCase, Action> onMessageReceived) where T: class, IMessage, new(); + class Null : IMessagePipe { private Null() {} @@ -33,6 +39,7 @@ private Null() {} throw new Exception("Null implementation"); public void Subscribe(Packet.MessageOneofCase _, Action> __, ThreadStrict ___) where T: class, IMessage, new() { } + public void Unsubscribe(Packet.MessageOneofCase _, Action> __) where T: class, IMessage, new() { } public void Dispose() { } } } diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/LogMessagePipe.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/LogMessagePipe.cs index 10b9f58bbf4..55e7c26d9f9 100644 --- a/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/LogMessagePipe.cs +++ b/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/LogMessagePipe.cs @@ -42,6 +42,14 @@ public LogMessagePipe(IMessagePipe origin, string fromPipe) ); } + public void Unsubscribe(Packet.MessageOneofCase ofCase, Action> onMessageReceived) where T: class, IMessage, new() + { + ReportHub.Log( + ReportCategory.LIVEKIT, $"From: {fromPipe} LogMessagePipe: Unsubscribing from messages of type {typeof(T).FullName}"); + + origin.Unsubscribe(ofCase, onMessageReceived); + } + public void Dispose() { origin.Dispose(); diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/MessagePipe.cs b/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/MessagePipe.cs index 17121370444..ea7a897a521 100644 --- a/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/MessagePipe.cs +++ b/Explorer/Assets/DCL/Multiplayer/Connections/Messaging/Pipe/MessagePipe.cs @@ -174,6 +174,14 @@ private async UniTaskVoid NotifySubscribersAsync(Packet.MessageOneofCase name, P ); } + public void Unsubscribe(Packet.MessageOneofCase ofCase, Action> onMessageReceived) where T: class, IMessage, new() + { + // Only one subscriber per type is allowed (see Subscribe), so dropping the type's entry removes + // that single subscription. An in-flight NotifySubscribersAsync holds its own list reference, so + // removing the dictionary key here does not disrupt a notification already in progress. + subscribers.Remove(ofCase); + } + private (List> list, IMessagePipe.ThreadStrict strict) SubscribersList(Packet.MessageOneofCase typeName, IMessagePipe.ThreadStrict threadStrict) { if (subscribers.TryGetValue(typeName, out (List> list, IMessagePipe.ThreadStrict strict) item) == false) diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceRoomFactory.cs b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceRoomFactory.cs new file mode 100644 index 00000000000..ab98cd82509 --- /dev/null +++ b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceRoomFactory.cs @@ -0,0 +1,46 @@ +using DCL.Multiplayer.Connections.DecentralandUrls; +using DCL.Multiplayer.Connections.Messaging.Pipe; +using DCL.Multiplayer.Connections.Pools; +using DCL.Multiplayer.Connections.Rooms; +using DCL.Multiplayer.Connections.Rooms.Connective; +using DCL.Web3.Identities; +using DCL.WebRequests; +using ECS; +using LiveKit.Internal.FFIClients.Pools; +using LiveKit.Internal.FFIClients.Pools.Memory; +using System.Buffers; + +namespace DCL.Multiplayer.Connections.PortableExperiences +{ + /// + /// Stateless factory that builds a scene-level LiveKit room () plus a + /// over its data channel for an authoritative Portable Experience scene. The returned + /// room is not started; the caller owns its lifecycle (start it during the scene load, dispose it on unload). + /// + public class PortableExperienceRoomFactory + { + private readonly IWebRequestController webRequests; + private readonly IWeb3IdentityCache identityCache; + private readonly IDecentralandUrlsSource urlsSource; + private readonly IMultiPool multiPool; + private readonly IMemoryPool memoryPool; + + public PortableExperienceRoomFactory(IWebRequestController webRequests, IWeb3IdentityCache identityCache, IDecentralandUrlsSource urlsSource) + { + this.webRequests = webRequests; + this.identityCache = identityCache; + this.urlsSource = urlsSource; + multiPool = new DCLMultiPool(); + memoryPool = new ArrayMemoryPool(ArrayPool.Shared!); + } + + public (IConnectiveRoom room, IMessagePipe pipe) Create(IRealmData portableExperienceRealm, string sceneId) + { + var room = new PortableExperienceSceneRoom(webRequests, identityCache, urlsSource, portableExperienceRealm, sceneId); + IMessagePipe pipe = new MessagePipe(room.Room().DataPipe, multiPool, memoryPool, RoomSource.GATEKEEPER) + .WithLog("PortableExperienceScene"); + + return (room, pipe); + } + } +} diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceWorldComms.cs.meta b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceRoomFactory.cs.meta similarity index 100% rename from Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceWorldComms.cs.meta rename to Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceRoomFactory.cs.meta diff --git a/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceWorldComms.cs b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceWorldComms.cs deleted file mode 100644 index fed32030cf8..00000000000 --- a/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceWorldComms.cs +++ /dev/null @@ -1,162 +0,0 @@ -using Cysharp.Threading.Tasks; -using DCL.Diagnostics; -using DCL.LiveKit.Public; -using DCL.Multiplayer.Connections.DecentralandUrls; -using DCL.Multiplayer.Connections.Messaging.Pipe; -using DCL.Multiplayer.Connections.Messaging.Throughput; -using DCL.Multiplayer.Connections.Rooms; -using DCL.Multiplayer.Connections.Rooms.Connective; -using DCL.Multiplayer.Connections.Systems.Throughput; -using DCL.Web3.Identities; -using DCL.WebRequests; -using ECS; -using LiveKit.Internal.FFIClients.Pools; -using LiveKit.Internal.FFIClients.Pools.Memory; -using System; -using System.Collections.Generic; - -namespace DCL.Multiplayer.Connections.PortableExperiences -{ - /// - /// Owns, per running authoritative Portable Experience scene, a scene-level LiveKit connection - /// () plus a over its data channel. - /// Joining the scene room carries the sceneId, which makes the world-content-server spawn the authoritative - /// server (Hammurabi). Connections are keyed by sceneId (entity hash) and reconciled against the set of - /// currently-loaded authoritative scenes via . The message pipe is exposed via - /// so the scene comms multiplexer can route that scene's CRDT to/from the server; - /// this class never references the scene-runtime pipe (assembly direction is SceneRuntime → DCL.Multiplayer). - /// - public class PortableExperienceWorldComms : IDisposable - { - private readonly IWebRequestController webRequests; - private readonly IWeb3IdentityCache identityCache; - private readonly IDecentralandUrlsSource urlsSource; - private readonly IMultiPool multiPool; - private readonly IMemoryPool memoryPool; - private readonly Dictionary entriesBySceneId = new (); - private readonly List staleSceneIdsBuffer = new (); - - public PortableExperienceWorldComms(IWebRequestController webRequests, IWeb3IdentityCache identityCache, IDecentralandUrlsSource urlsSource, - IMultiPool multiPool, IMemoryPool memoryPool) - { - this.webRequests = webRequests; - this.identityCache = identityCache; - this.urlsSource = urlsSource; - this.multiPool = multiPool; - this.memoryPool = memoryPool; - } - - public void Dispose() - { - foreach (Entry entry in entriesBySceneId.Values) - DisposeEntry(entry); - - entriesBySceneId.Clear(); - } - - /// - /// Connects to the scene room of the experience the first time it is requested for a sceneId. Idempotent: - /// repeated calls for an already-connected sceneId are ignored. - /// - public void EnsureConnected(string sceneId, IRealmData portableExperienceRealm) - { - if (entriesBySceneId.ContainsKey(sceneId)) return; - - var room = new PortableExperienceSceneRoom(webRequests, identityCache, urlsSource, portableExperienceRealm, sceneId); - IMessagePipe pipe = new MessagePipe(room.Room().DataPipe.WithThroughputMeasure(new ThroughputBufferBunch(new ThroughputBuffer(), new ThroughputBuffer())), - multiPool, memoryPool, RoomSource.GATEKEEPER) - .WithLog("PortableExperienceScene"); - - entriesBySceneId[sceneId] = new Entry(room, pipe); - - ReportHub.Log(ReportCategory.COMMS_SCENE_HANDLER, $"Portable Experience comms: connecting scene '{sceneId}' of world '{portableExperienceRealm.RealmName}'"); - ConnectAsync(sceneId, room).Forget(); - } - - /// - /// Returns the LiveKit room and its message pipe for a connected scene, regardless of connection state. - /// - public bool TryGetRoom(string sceneId, out IConnectiveRoom room, out IMessagePipe pipe) - { - if (entriesBySceneId.TryGetValue(sceneId, out Entry entry)) - { - room = entry.Room; - pipe = entry.Pipe; - return true; - } - - room = null!; - pipe = null!; - return false; - } - - /// - /// True when the scene's Portable Experience room exists and its LiveKit connection is established. Used to - /// report RealmInfo.isConnectedSceneRoom for PX scenes so the SDK initiates the CRDT handshake. - /// - public bool IsConnected(string sceneId) => - entriesBySceneId.TryGetValue(sceneId, out Entry entry) - && entry.Room.CurrentState() == IConnectiveRoom.State.Running - && entry.Room.Room().Info.ConnectionState == LKConnectionState.ConnConnected; - - /// - /// Disconnects and disposes every scene whose sceneId is no longer present in . - /// - public void RetainOnly(ICollection liveSceneIds) - { - if (entriesBySceneId.Count == 0) return; - - staleSceneIdsBuffer.Clear(); - - foreach (string sceneId in entriesBySceneId.Keys) - if (!liveSceneIds.Contains(sceneId)) - staleSceneIdsBuffer.Add(sceneId); - - foreach (string sceneId in staleSceneIdsBuffer) - { - Entry entry = entriesBySceneId[sceneId]; - entriesBySceneId.Remove(sceneId); - - ReportHub.Log(ReportCategory.COMMS_SCENE_HANDLER, $"Portable Experience comms: disconnecting scene '{sceneId}'"); - DisposeEntry(entry); - } - } - - private static void DisposeEntry(Entry entry) - { - entry.Pipe.Dispose(); - StopAndDisposeAsync(entry.Room).Forget(); - } - - private static async UniTaskVoid ConnectAsync(string sceneId, IConnectiveRoom room) - { - try - { - bool connected = await room.StartIfNotAsync(); - ReportHub.Log(ReportCategory.COMMS_SCENE_HANDLER, $"Portable Experience comms: scene '{sceneId}' connection finished, connected={connected}"); - } - catch (OperationCanceledException) { } - catch (Exception e) { ReportHub.LogException(e, new ReportData(ReportCategory.COMMS_SCENE_HANDLER)); } - } - - private static async UniTaskVoid StopAndDisposeAsync(IConnectiveRoom room) - { - try { await room.StopIfNotAsync(); } - catch (OperationCanceledException) { } - catch (Exception e) { ReportHub.LogException(e, new ReportData(ReportCategory.COMMS_SCENE_HANDLER)); } - finally { room.Dispose(); } - } - - private readonly struct Entry - { - public readonly IConnectiveRoom Room; - public readonly IMessagePipe Pipe; - - public Entry(IConnectiveRoom room, IMessagePipe pipe) - { - Room = room; - Pipe = pipe; - } - } - } -} diff --git a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms.meta b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms.meta deleted file mode 100644 index d6209dc7ffc..00000000000 --- a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms.meta +++ /dev/null @@ -1,8 +0,0 @@ -fileFormatVersion: 2 -guid: 2e72d296e0bd6498db14b18f37495c40 -folderAsset: yes -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs deleted file mode 100644 index b38a9977963..00000000000 --- a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Arch.SystemGroups; -using CrdtEcsBridge.JsModulesImplementation.Communications; -using DCL.Multiplayer.Connections.PortableExperiences; - -namespace DCL.PluginSystem.Global.PortableExperienceComms -{ - /// - /// Global plugin that injects so running authoritative Portable - /// Experiences connect to their world scene room (which makes the world-content-server spawn the authoritative - /// server) and exchange CRDT with it over that room. The underlying connections are owned and disposed by - /// CommsContainer via . - /// - public class PortableExperienceCommsPlugin : IDCLGlobalPluginWithoutSettings - { - private readonly PortableExperienceWorldComms worldComms; - private readonly SceneCommunicationPipe sceneCommunicationPipe; - - public PortableExperienceCommsPlugin(PortableExperienceWorldComms worldComms, SceneCommunicationPipe sceneCommunicationPipe) - { - this.worldComms = worldComms; - this.sceneCommunicationPipe = sceneCommunicationPipe; - } - - public void InjectToWorld(ref ArchSystemsWorldBuilder builder, in GlobalPluginArguments arguments) - { - PortableExperienceWorldCommsSystem.InjectToWorld(ref builder, worldComms, sceneCommunicationPipe); - } - } -} diff --git a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs.meta b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs.meta deleted file mode 100644 index 9e2e6b4ee24..00000000000 --- a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceCommsPlugin.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 14298238129a049ad9650c281b0b9f30 \ No newline at end of file diff --git a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs deleted file mode 100644 index 12ede87b0a3..00000000000 --- a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Arch.Core; -using Arch.System; -using Arch.SystemGroups; -using Arch.SystemGroups.DefaultSystemGroups; -using CrdtEcsBridge.JsModulesImplementation.Communications; -using DCL.Multiplayer.Connections.Messaging.Pipe; -using DCL.Multiplayer.Connections.PortableExperiences; -using DCL.Multiplayer.Connections.Rooms.Connective; -using ECS; -using ECS.Abstract; -using ECS.LifeCycle.Components; -using ECS.SceneLifeCycle.SceneDefinition; -using System.Collections.Generic; - -// Lives in the multiplayer-connections namespace (not DCL.PluginSystem.*) on purpose: the -// DCL.PluginSystem.World namespace would shadow Arch.Core.World, and the Arch query source -// generator emits `World` unqualified into this type's namespace. -namespace DCL.Multiplayer.Connections.PortableExperiences -{ - /// - /// Keeps the comms wiring of each running authoritative Portable Experience scene in sync with the loaded - /// experiences. For every authoritative PX scene it (1) ensures a scene-level LiveKit connection exists - /// (so the world-content-server spawns the authoritative server) and (2) registers that room with the shared - /// , so the scene's CRDT is exchanged with the authoritative-server - /// over the PX room instead of the host's current scene room. Worlds whose scene is no longer loaded are - /// unregistered and disconnected. - /// - [UpdateInGroup(typeof(PresentationSystemGroup))] - public partial class PortableExperienceWorldCommsSystem : BaseUnityLoopSystem - { - private readonly PortableExperienceWorldComms worldComms; - private readonly SceneCommunicationPipe sceneCommunicationPipe; - private readonly Dictionary realmDataByEns = new (); - private readonly HashSet liveSceneIds = new (); - - internal PortableExperienceWorldCommsSystem(World world, PortableExperienceWorldComms worldComms, SceneCommunicationPipe sceneCommunicationPipe) : base(world) - { - this.worldComms = worldComms; - this.sceneCommunicationPipe = sceneCommunicationPipe; - } - - protected override void Update(float t) - { - realmDataByEns.Clear(); - liveSceneIds.Clear(); - - // The Portable Experience realm entity carries the world's comms data (keyed by ENS); the scene entity - // carries the authoritativeMultiplayer flag and the sceneId. Collect the realms first, then connect and - // register the scene rooms of authoritative scenes and reconcile the rest away. - CollectRealmDataQuery(World); - ActivateAuthoritativeCommsQuery(World); - - // Stop routing for unloaded scenes before disposing their rooms. - sceneCommunicationPipe.RetainOnlyRooms(liveSceneIds); - worldComms.RetainOnly(liveSceneIds); - } - - [Query] - [None(typeof(DeleteEntityIntention))] - private void CollectRealmData(in PortableExperienceComponent portableExperience, in PortableExperienceRealmComponent realm) - { - realmDataByEns[portableExperience.Ens.ToString()] = realm.RealmData; - } - - [Query] - [None(typeof(PortableExperienceRealmComponent), typeof(DeleteEntityIntention))] - private void ActivateAuthoritativeComms(in PortableExperienceComponent portableExperience, in SceneDefinitionComponent sceneDefinition) - { - if (!sceneDefinition.IsPortableExperience) return; - if (!sceneDefinition.Definition.metadata.authoritativeMultiplayer) return; - - if (!realmDataByEns.TryGetValue(portableExperience.Ens.ToString(), out IRealmData realmData)) return; - - string sceneId = sceneDefinition.Definition.id; - if (string.IsNullOrEmpty(sceneId)) return; - - liveSceneIds.Add(sceneId); - - worldComms.EnsureConnected(sceneId, realmData); - - if (worldComms.TryGetRoom(sceneId, out IConnectiveRoom room, out IMessagePipe roomPipe)) - sceneCommunicationPipe.RegisterSceneRoom(sceneId, room, roomPipe); - } - } -} diff --git a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs.meta b/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs.meta deleted file mode 100644 index 3302e7400fb..00000000000 --- a/Explorer/Assets/DCL/PluginSystem/Global/PortableExperienceComms/PortableExperienceWorldCommsSystem.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 93aa758c0dedd402890ef0a6d69dba4c \ No newline at end of file diff --git a/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs b/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs index eff2deef24c..f7afc19c07c 100644 --- a/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs +++ b/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs @@ -1,10 +1,10 @@ using Arch.SystemGroups; -using DCL.Multiplayer.Connections.PortableExperiences; using DCL.Multiplayer.Connections.RoomHubs; using DCL.PluginSystem.World.Dependencies; using DCL.SDKComponents.RealmInfo; using ECS; using ECS.LifeCycle; +using ECS.SceneLifeCycle; using System.Collections.Generic; namespace DCL.PluginSystem.World @@ -13,13 +13,13 @@ public class RealmInfoPlugin : IDCLWorldPluginWithoutSettings { private readonly IRealmData realmData; private readonly IRoomHub roomHub; - private readonly PortableExperienceWorldComms portableExperienceWorldComms; + private readonly IScenesCache scenesCache; - public RealmInfoPlugin(IRealmData realmData, IRoomHub roomHub, PortableExperienceWorldComms portableExperienceWorldComms) + public RealmInfoPlugin(IRealmData realmData, IRoomHub roomHub, IScenesCache scenesCache) { this.realmData = realmData; this.roomHub = roomHub; - this.portableExperienceWorldComms = portableExperienceWorldComms; + this.scenesCache = scenesCache; } public void Dispose() @@ -29,7 +29,7 @@ public void Dispose() public void InjectToWorld(ref ArchSystemsWorldBuilder builder, in ECSWorldInstanceSharedDependencies sharedDependencies, in SystemsDependencies systemsDependencies, in PersistentEntities persistentEntities, List finalizeWorldSystems, List sceneIsCurrentListeners) { - WriteRealmInfoSystem.InjectToWorld(ref builder, sharedDependencies.EcsToCRDTWriter, realmData, roomHub, sharedDependencies.SceneData, portableExperienceWorldComms); + WriteRealmInfoSystem.InjectToWorld(ref builder, sharedDependencies.EcsToCRDTWriter, realmData, roomHub, sharedDependencies.SceneData, scenesCache); } } } diff --git a/Explorer/Assets/DCL/SDKComponents/RealmInfo/Tests/WriteRealmInfoSystemShould.cs b/Explorer/Assets/DCL/SDKComponents/RealmInfo/Tests/WriteRealmInfoSystemShould.cs index 45f00eb8f16..9f3ab30f991 100644 --- a/Explorer/Assets/DCL/SDKComponents/RealmInfo/Tests/WriteRealmInfoSystemShould.cs +++ b/Explorer/Assets/DCL/SDKComponents/RealmInfo/Tests/WriteRealmInfoSystemShould.cs @@ -10,6 +10,7 @@ using DCL.Multiplayer.Connections.Rooms.Connective; using DCL.Utilities; using ECS; +using ECS.SceneLifeCycle; using ECS.TestSuite; using LiveKit.Rooms; using LiveKit.Rooms.Info; @@ -26,6 +27,8 @@ public class WriteRealmInfoSystemShould : UnitySystemTestBase(); + sceneData = Substitute.For(); sceneData.SceneEntityDefinition.Returns(new SceneEntityDefinition()); IGateKeeperSceneRoom? gateKeeperSceneRoom = Substitute.For(); @@ -58,14 +61,9 @@ public void Setup() roomHub.SceneRoom().Returns(gateKeeperSceneRoom); - var portableExperienceWorldComms = new DCL.Multiplayer.Connections.PortableExperiences.PortableExperienceWorldComms( - Substitute.For(), - Substitute.For(), - Substitute.For(), - Substitute.For(), - Substitute.For()); + scenesCache = Substitute.For(); - system = new WriteRealmInfoSystem(world, ecsToCRDTWriter, realmData, roomHub, sceneData, portableExperienceWorldComms); + system = new WriteRealmInfoSystem(world, ecsToCRDTWriter, realmData, roomHub, sceneData, scenesCache); } protected override void OnTearDown() @@ -156,5 +154,23 @@ public void WriteRealmInfoDataOnInitializeRegardlessOfIsDirtyFlag() AssertPutMessageReceived(); } + + [Test] + public void OmitHostSceneRoomCheckForPortableExperience() + { + // For a PX the connection state is read from the scene's own facade (via the scenes cache), not the + // host's scene room: with no PX facade registered the scene reports as not connected even though the + // host scene room ('gateKeeperSceneRoom') is connected. + sceneData.IsPortableExperience().Returns(true); + sceneData.SceneEntityDefinition.Returns(new SceneEntityDefinition { id = "px-scene" }); + + system.Update(0); + + ecsToCRDTWriter.Received(1) + .PutMessage( + Arg.Any>(), + SpecialEntitiesID.SCENE_ROOT_ENTITY, + Arg.Is<(IRealmData realmData, WriteRealmInfoSystem.CommsRoomInfo roomInfo)>(data => !data.roomInfo.IsConnectedSceneRoom)); + } } } diff --git a/Explorer/Assets/DCL/SDKComponents/RealmInfo/WriteRealmInfoSystem.cs b/Explorer/Assets/DCL/SDKComponents/RealmInfo/WriteRealmInfoSystem.cs index 91b158caa56..ab6968a69e1 100644 --- a/Explorer/Assets/DCL/SDKComponents/RealmInfo/WriteRealmInfoSystem.cs +++ b/Explorer/Assets/DCL/SDKComponents/RealmInfo/WriteRealmInfoSystem.cs @@ -3,13 +3,12 @@ using CrdtEcsBridge.Components; using CrdtEcsBridge.ECSToCRDTWriter; using DCL.ECSComponents; -using DCL.Multiplayer.Connections.GateKeeper.Rooms; -using DCL.Multiplayer.Connections.PortableExperiences; using DCL.Multiplayer.Connections.RoomHubs; -using DCL.Multiplayer.Connections.Rooms.Connective; using ECS; using ECS.Abstract; using ECS.Groups; +using ECS.SceneLifeCycle; +using SceneRunner; using SceneRunner.Scene; namespace DCL.SDKComponents.RealmInfo @@ -23,13 +22,13 @@ public partial class WriteRealmInfoSystem : BaseUnityLoopSystem private bool initialized; - internal WriteRealmInfoSystem(World world, IECSToCRDTWriter ecsToCRDTWriter, IRealmData realmData, IRoomHub roomHub, ISceneData sceneData, PortableExperienceWorldComms portableExperienceWorldComms) + internal WriteRealmInfoSystem(World world, IECSToCRDTWriter ecsToCRDTWriter, IRealmData realmData, IRoomHub roomHub, ISceneData sceneData, IScenesCache scenesCache) : base(world) { this.ecsToCRDTWriter = ecsToCRDTWriter; this.realmData = realmData; - commsRoomInfo = new CommsRoomInfo(roomHub, sceneData, portableExperienceWorldComms); + commsRoomInfo = new CommsRoomInfo(roomHub, sceneData, scenesCache); } public override void Initialize() @@ -72,17 +71,17 @@ public class CommsRoomInfo { private readonly IRoomHub roomHub; private readonly ISceneData sceneData; - private readonly PortableExperienceWorldComms portableExperienceWorldComms; + private readonly IScenesCache scenesCache; public string IslandSid { get; private set; } public bool IsConnectedSceneRoom { get; private set; } - public CommsRoomInfo(IRoomHub roomHub, ISceneData sceneData, PortableExperienceWorldComms portableExperienceWorldComms) + public CommsRoomInfo(IRoomHub roomHub, ISceneData sceneData, IScenesCache scenesCache) { this.roomHub = roomHub; this.sceneData = sceneData; - this.portableExperienceWorldComms = portableExperienceWorldComms; + this.scenesCache = scenesCache; } /// @@ -91,12 +90,12 @@ public CommsRoomInfo(IRoomHub roomHub, ISceneData sceneData, PortableExperienceW /// public bool TryFetchNewInfo() { - IGateKeeperSceneRoom sceneRoom = roomHub.SceneRoom(); - - // A Portable Experience scene is connected through its own scene room (not the host's current scene room), - // so the SDK only initiates the CRDT handshake when this reflects the PX room's connection. - bool isConnectedToSceneRoom = sceneRoom.IsSceneConnected(sceneData.SceneEntityDefinition.id) - || portableExperienceWorldComms.IsConnected(sceneData.SceneEntityDefinition.id); + // A Portable Experience scene connects through its own scene room, owned by its facade — not the + // host's current scene room — so for a PX the connection state is read from the scene's facade and + // the host scene-room check is omitted entirely (it is always false for a PX). + bool isConnectedToSceneRoom = sceneData.IsPortableExperience() + ? IsPortableExperienceSceneRoomConnected() + : roomHub.SceneRoom().IsSceneConnected(sceneData.SceneEntityDefinition.id); string room = roomHub.IslandRoom().Info.Sid ?? string.Empty; @@ -109,6 +108,15 @@ public bool TryFetchNewInfo() return true; } + private bool IsPortableExperienceSceneRoomConnected() + { + string sceneId = sceneData.SceneEntityDefinition.id; + + return !string.IsNullOrEmpty(sceneId) + && scenesCache.TryGetPortableExperienceBySceneUrn(sceneId, out ISceneFacade facade) + && facade is PortableExperienceSceneFacade { IsConnectedSceneRoom: true }; + } + public void WriteToComponent(PBRealmInfo component) { component.Room = IslandSid; diff --git a/docs/authoritative-portable-experiences.md b/docs/authoritative-portable-experiences.md index 8f052d23fd3..0a87d3cd0d9 100644 --- a/docs/authoritative-portable-experiences.md +++ b/docs/authoritative-portable-experiences.md @@ -70,30 +70,42 @@ never spawns. ## Client architecture -All client code lives in `DCL.Multiplayer` (the comms primitives), `SceneRuntime` -(the scene comms pipe), and `DCL.Plugins` (the system and plugin). No new -assembly was introduced. +The PX room is **owned by the scene's lifecycle**: connected as a blocking step +of the scene load and disposed when the scene's facade is disposed. There is no +global system polling scene state. Client code lives in `DCL.Multiplayer` (the +comms primitives), `SceneRuntime` (the scene comms pipe), and the scene-loading / +facade code in `SceneLifeCycle` and `SceneRunner`. No new assembly was introduced. | Component | Assembly | Responsibility | |---|---|---| | `PortableExperienceSceneRoom` | `DCL.Multiplayer` | A `ConnectiveRoom` that connects to `worlds//scenes//comms` with the `sceneId` in the handshake metadata. Always targets the scene endpoint. | -| `PortableExperienceWorldComms` | `DCL.Multiplayer` | Owns one `PortableExperienceSceneRoom` plus a `MessagePipe` per authoritative PX scene, keyed by sceneId. Exposes `EnsureConnected`, `TryGetRoom`, `IsConnected`, `RetainOnly`. | -| `SceneCommunicationPipe` (extended) | `SceneRuntime` | Gained multi-room routing: `RegisterSceneRoom` / `RetainOnlyRooms`. Outbound, inbound, and `IsSceneConnected` for a registered sceneId route to that scene's PX room instead of the host's current scene room. The host-room path is unchanged. | -| `PortableExperienceWorldCommsSystem` + `PortableExperienceCommsPlugin` | `DCL.Plugins` | Global-world system that, each frame, connects the room for every authoritative PX scene, registers the room with the pipe, and reconciles rooms whose scene unloaded. | -| `WriteRealmInfoSystem` (extended) | `DCL.Plugins` | Reports `RealmInfo.isConnectedSceneRoom == true` when the PX room is connected (not only the host scene room). | +| `PortableExperienceRoomFactory` | `DCL.Multiplayer` | Stateless builder of a `PortableExperienceSceneRoom` plus its `MessagePipe` for a given realm and sceneId. Holds the LiveKit pools the message pipe needs. | +| `PortableExperienceSceneFacade` | `SceneRunner` | Owns the room and pipe for its PX scene. Connects during load (`StartAuthoritativeRoomAsync`), exposes `IsConnectedSceneRoom`, and removes routing from the pipe + stops/disposes the room in `DisposeInternal`. | +| `LoadSceneSystemLogicBase` (extended) | `SceneLifeCycle` | After the facade is built, awaits the PX room connect as a load precondition — a failed connect fails the load (block-on-load). | +| `SceneCommunicationPipe` (extended) | `SceneRuntime` | Gained per-scene room routing: `RegisterSceneRoom` / `RemoveSceneRoom`. Outbound, inbound, and `IsSceneConnected` for a registered sceneId route to that scene's PX room instead of the host's current scene room. The host-room path is unchanged. | +| `WriteRealmInfoSystem` (extended) | `Assembly-CSharp` | For a PX, reports `RealmInfo.isConnectedSceneRoom` from the scene's own facade (looked up via `IScenesCache`), omitting the host-room check entirely. | ### Wiring and ownership The shared `SceneCommunicationPipe` is created once in `CommsContainer` (hoisted -out of `SceneSharedContainer` so both the scene factory and the PX plugin share -one instance, avoiding an `ObjectProxy`). `PortableExperienceWorldComms` is also -created in `CommsContainer`, which already has the LiveKit pools the per-room -message pipes need. - -The `DCL.Plugins` system is the only place that legally sees both sides: the -rooms (in `DCL.Multiplayer`) and the pipe (in `SceneRuntime`). The dependency -direction is `SceneRuntime → DCL.Multiplayer`, never the reverse, so the service -never references the pipe — the system registers rooms with the pipe instead. +out of `SceneSharedContainer` so both the scene factory and the scene-loading +flow share one instance, avoiding an `ObjectProxy`). `SceneFactory` builds a +`PortableExperienceRoomFactory` and hands it — plus the shared pipe — to every +`PortableExperienceSceneFacade` it creates. + +The room is thus owned by the facade cradle-to-grave. It is connected as an +awaited step of `LoadSceneSystemLogicBase.FlowAsync` (joining the room triggers +the server spawn, so the PX is not considered loaded until connected) and torn +down — routing removed from the pipe, room stopped and disposed — when the facade +is disposed by the normal unload path (`UnloadSceneSystem`). No global system +reconciles scene state every frame. + +The PX `RealmData` the room needs reaches the load flow on the scene entity's +`PortableExperienceComponent` (set by `ECSPortableExperiencesController` where the +realm is created), which `ResolveSceneStateByIncreasingRadiusSystem` reads into +`GetSceneFacadeIntention.PortableExperienceRealm` for authoritative PX scenes. +The dependency direction stays `SceneRuntime → DCL.Multiplayer`; the pipe never +references the rooms — the facade registers its room with the pipe instead. ## Constraints that shaped the design @@ -116,18 +128,9 @@ These are the non-obvious rules that ruled out simpler approaches. scene endpoint when `IsWorld() && !SingleScene`, so reusing `GateKeeperSceneRoom` for a PX would hit the wrong (world-level) endpoint. `PortableExperienceSceneRoom` bypasses this by always building the scene-level URL. -- **Assembly direction.** Systems must compile into `DCL.Plugins` (they reference - many assemblies). `SceneRuntime` may reference `DCL.Multiplayer`, never the - reverse. These rules dictated where each piece lives. - - -> [!NOTE] -> A namespace gotcha: code under `DCL.PluginSystem.*` cannot use the unqualified -> `World` type, because the `DCL.PluginSystem.World` namespace shadows -> `Arch.Core.World` and the Arch query source generator emits `World` unqualified. -> `PortableExperienceWorldCommsSystem` therefore lives in the -> `DCL.Multiplayer.Connections.PortableExperiences` namespace even though its -> file sits under `PluginSystem/Global/`. +- **Assembly direction.** `SceneRuntime` may reference `DCL.Multiplayer`, never + the reverse. The pipe (in `SceneRuntime`) never references the rooms (in + `DCL.Multiplayer`); the facade registers its room with the pipe instead. ## The three problems we solved @@ -137,9 +140,10 @@ gaps, each surfaced by a different symptom. ### 1. No spawn trigger By default a PX never joins any scene comms room, so the server side never sees -a user and never spawns. The fix is `PortableExperienceWorldComms` plus its -driving system: when an authoritative PX scene is running, the client opens a -data-only LiveKit connection to that world's comms. +a user and never spawns. The fix wires the room into the scene's own lifecycle: +when an authoritative PX scene loads, `PortableExperienceSceneFacade` opens a +data-only LiveKit connection (`PortableExperienceSceneRoom`) to that world's +scene comms, awaited as a load precondition. ### 2. Wrong room — the join carried no sceneId @@ -177,9 +181,10 @@ The client writes `RealmInfo` through `WriteRealmInfoSystem`, which computed never connected to a PX scene, so always `false`. The SDK therefore never requested state. -The fix makes `WriteRealmInfoSystem` also report `true` when the PX room is -connected, via `PortableExperienceWorldComms.IsConnected(sceneId)`. When the PX -room finishes connecting, the flag flips, `RealmInfo.onChange` fires, +The fix makes `WriteRealmInfoSystem` branch for a PX: instead of the host scene +room (always `false`, a false signal), it reads `IsConnectedSceneRoom` from the +scene's own `PortableExperienceSceneFacade`, looked up via `IScenesCache`. When +the PX room finishes connecting, the flag flips, `RealmInfo.onChange` fires, `requestState()` runs, and `REQ_CRDT_STATE` finally reaches `authoritative-server`, which responds with `RES_CRDT_STATE` and ongoing CRDT deltas. @@ -231,13 +236,6 @@ These cost real time during development; keep them in mind when debugging comms. `REQ_CRDT_STATE` (`firstByte=2`), and the client receives `RES_CRDT_STATE` from the server. - -> [!NOTE] -> Temporary `[PX-COMMS]` diagnostic logs were added to `SceneCommunicationPipe`, -> `PortableExperienceWorldComms`, and `CommunicationsControllerAPIImplementationBase` -> to trace the connect → register → send → receive chain. Remove them once the -> feature is verified. - ## See also - [LiveKit Networking](livekit-networking.md) — rooms, message pipes, GateKeeper From 1405264d4b8b3a0e8d98f8b451af63a486c5de19 Mon Sep 17 00:00:00 2001 From: Gabriel Diaz Date: Thu, 25 Jun 2026 15:54:06 +0200 Subject: [PATCH 3/3] refactor: fail PX load when the authoritative scene room can't connect Mikhail's review: the connect must be a load precondition 'without which the PX can't be loaded'. Awaiting StartAsync but discarding its result let a soft connection failure proceed; now a failed join throws and fails the load. --- .../SceneRunner/PortableExperienceSceneFacade.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Explorer/Assets/DCL/Infrastructure/SceneRunner/PortableExperienceSceneFacade.cs b/Explorer/Assets/DCL/Infrastructure/SceneRunner/PortableExperienceSceneFacade.cs index abb3c561d54..abe99afab70 100644 --- a/Explorer/Assets/DCL/Infrastructure/SceneRunner/PortableExperienceSceneFacade.cs +++ b/Explorer/Assets/DCL/Infrastructure/SceneRunner/PortableExperienceSceneFacade.cs @@ -56,7 +56,13 @@ public async UniTask StartAuthoritativeRoomAsync(IRealmData portableExperienceRe sceneCommunicationPipe.RegisterSceneRoom(authoritativeSceneId, authoritativeRoom, authoritativeRoomPipe); ReportHub.Log(ReportCategory.COMMS_SCENE_HANDLER, $"Portable Experience comms: connecting scene '{authoritativeSceneId}' of world '{portableExperienceRealm.RealmName}'"); - await authoritativeRoom.StartAsync().AttachExternalCancellation(ct); + + // The connection is a precondition of loading the PX: joining the room is what makes the server spawn, + // so a failed join must fail the load (the caller disposes this facade, tearing the room down). + bool connected = await authoritativeRoom.StartAsync().AttachExternalCancellation(ct); + + if (!connected) + throw new Exception($"Could not connect the authoritative scene room for Portable Experience '{authoritativeSceneId}'"); } protected override void DisposeInternal()