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 5f6647457bf..d6c2e7937fb 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,10 @@ 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 ();
+
public SceneCommunicationPipe(IMessagePipesHub messagePipesHub, IGateKeeperSceneRoom sceneRoom)
{
this.sceneRoom = sceneRoom;
@@ -31,6 +36,40 @@ 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 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);
+ }
+ }
+
+ ///
+ /// 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 RemoveSceneRoom(string sceneId)
+ {
+ lock (extraRoomsBySceneId)
+ {
+ if (!extraRoomsBySceneId.Remove(sceneId, out RoomChannel channel)) return;
+
+ channel.Pipe.Unsubscribe(Packet.MessageOneofCase.Scene, InvokeSubscriber);
+ }
+ }
+
private void InvokeSubscriber(ReceivedMessage message)
{
using (message)
@@ -44,7 +83,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 +147,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;
+
+ 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 = messagePipe.NewMessage();
+ MessageWrap sceneMessage = pipe.NewMessage();
if (!string.IsNullOrEmpty(specialRecipient))
sceneMessage.AddSpecialRecipient(specialRecipient);
@@ -120,6 +173,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 +206,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/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/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..582ccf7420f 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;
@@ -68,6 +69,8 @@ public class CommsContainer : IDisposable
public CurrentSceneInfo CurrentSceneInfo { get; }
+ public SceneCommunicationPipe SceneCommunicationPipe { get; }
+
private CommsContainer(
IArchipelagoIslandRoom archipelagoIslandRoom,
IGateKeeperSceneRoom gateKeeperSceneRoom,
@@ -83,7 +86,8 @@ private CommsContainer(
RemoteEntities remoteEntities,
IRemoteMetadata remoteMetadata,
IHealthCheck livekitHealthCheck,
- CurrentSceneInfo currentSceneInfo)
+ CurrentSceneInfo currentSceneInfo,
+ SceneCommunicationPipe sceneCommunicationPipe)
{
this.archipelagoIslandRoom = archipelagoIslandRoom;
this.gateKeeperSceneRoom = gateKeeperSceneRoom;
@@ -100,6 +104,7 @@ private CommsContainer(
RemoteMetadata = remoteMetadata;
LivekitHealthCheck = livekitHealthCheck;
CurrentSceneInfo = currentSceneInfo;
+ SceneCommunicationPipe = sceneCommunicationPipe;
}
public static CommsContainer Create(
@@ -201,6 +206,8 @@ public static CommsContainer Create(
? livekitHealthCheck.WithFailAnalytics(bootstrapContainer.Analytics.Controller)
: livekitHealthCheck;
+ var sceneCommunicationPipe = new SceneCommunicationPipe(messagePipesHub, roomHub.SceneRoom());
+
return new CommsContainer(
archipelagoIslandRoom,
gateKeeperSceneRoom,
@@ -216,7 +223,8 @@ public static CommsContainer Create(
remoteEntities,
remoteMetadata,
livekitHealthCheck,
- new CurrentSceneInfo());
+ new CurrentSceneInfo(),
+ sceneCommunicationPipe);
}
public MultiplayerPlugin CreateMultiplayerPlugin(
diff --git a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs
index 369c19a79d1..8b2f8bcb725 100644
--- a/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs
+++ b/Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs
@@ -48,6 +48,7 @@
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.SmartWearables;
@@ -130,6 +131,8 @@ public class DynamicWorldContainer : DCLWorldContainer
public IRoomHub RoomHub => commsContainer.RoomHub;
+ public SceneCommunicationPipe SceneCommunicationPipe => commsContainer.SceneCommunicationPipe;
+
public ISystemClipboard SystemClipboard => uiShellContainer.Clipboard;
private DynamicWorldContainer(
@@ -442,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),
+ new RealmInfoPlugin(staticContainer.RealmData, commsContainer.RoomHub, staticContainer.ScenesCache),
};
var characterPreviewEventBus = new CharacterPreviewEventBus();
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/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/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..abe99afab70 100644
--- a/Explorer/Assets/DCL/Infrastructure/SceneRunner/PortableExperienceSceneFacade.cs
+++ b/Explorer/Assets/DCL/Infrastructure/SceneRunner/PortableExperienceSceneFacade.cs
@@ -1,14 +1,90 @@
-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}'");
+
+ // 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()
+ {
+ 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.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/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/PortableExperienceRoomFactory.cs.meta b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceRoomFactory.cs.meta
new file mode 100644
index 00000000000..6b814822d1c
--- /dev/null
+++ b/Explorer/Assets/DCL/Multiplayer/Connections/PortableExperiences/PortableExperienceRoomFactory.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: efb5838e4fcf243ad96c2bf483571349
\ No newline at end of file
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/PluginSystem/World/RealmInfoPlugin.cs b/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs
index 804e378cd94..f7afc19c07c 100644
--- a/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs
+++ b/Explorer/Assets/DCL/PluginSystem/World/RealmInfoPlugin.cs
@@ -4,6 +4,7 @@
using DCL.SDKComponents.RealmInfo;
using ECS;
using ECS.LifeCycle;
+using ECS.SceneLifeCycle;
using System.Collections.Generic;
namespace DCL.PluginSystem.World
@@ -12,11 +13,13 @@ public class RealmInfoPlugin : IDCLWorldPluginWithoutSettings
{
private readonly IRealmData realmData;
private readonly IRoomHub roomHub;
+ private readonly IScenesCache scenesCache;
- public RealmInfoPlugin(IRealmData realmData, IRoomHub roomHub)
+ public RealmInfoPlugin(IRealmData realmData, IRoomHub roomHub, IScenesCache scenesCache)
{
this.realmData = realmData;
this.roomHub = roomHub;
+ this.scenesCache = scenesCache;
}
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, scenesCache);
}
}
}
diff --git a/Explorer/Assets/DCL/SDKComponents/RealmInfo/Tests/WriteRealmInfoSystemShould.cs b/Explorer/Assets/DCL/SDKComponents/RealmInfo/Tests/WriteRealmInfoSystemShould.cs
index 288ffb00920..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,7 +61,9 @@ public void Setup()
roomHub.SceneRoom().Returns(gateKeeperSceneRoom);
- system = new WriteRealmInfoSystem(world, ecsToCRDTWriter, realmData, roomHub, sceneData);
+ scenesCache = Substitute.For();
+
+ system = new WriteRealmInfoSystem(world, ecsToCRDTWriter, realmData, roomHub, sceneData, scenesCache);
}
protected override void OnTearDown()
@@ -149,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 e76c089b153..ab6968a69e1 100644
--- a/Explorer/Assets/DCL/SDKComponents/RealmInfo/WriteRealmInfoSystem.cs
+++ b/Explorer/Assets/DCL/SDKComponents/RealmInfo/WriteRealmInfoSystem.cs
@@ -3,12 +3,12 @@
using CrdtEcsBridge.Components;
using CrdtEcsBridge.ECSToCRDTWriter;
using DCL.ECSComponents;
-using DCL.Multiplayer.Connections.GateKeeper.Rooms;
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
@@ -22,13 +22,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, IScenesCache scenesCache)
: base(world)
{
this.ecsToCRDTWriter = ecsToCRDTWriter;
this.realmData = realmData;
- commsRoomInfo = new CommsRoomInfo(roomHub, sceneData);
+ commsRoomInfo = new CommsRoomInfo(roomHub, sceneData, scenesCache);
}
public override void Initialize()
@@ -71,15 +71,17 @@ public class CommsRoomInfo
{
private readonly IRoomHub roomHub;
private readonly ISceneData sceneData;
+ private readonly IScenesCache scenesCache;
public string IslandSid { get; private set; }
public bool IsConnectedSceneRoom { get; private set; }
- public CommsRoomInfo(IRoomHub roomHub, ISceneData sceneData)
+ public CommsRoomInfo(IRoomHub roomHub, ISceneData sceneData, IScenesCache scenesCache)
{
this.roomHub = roomHub;
this.sceneData = sceneData;
+ this.scenesCache = scenesCache;
}
///
@@ -88,8 +90,12 @@ public CommsRoomInfo(IRoomHub roomHub, ISceneData sceneData)
///
public bool TryFetchNewInfo()
{
- IGateKeeperSceneRoom sceneRoom = roomHub.SceneRoom();
- bool isConnectedToSceneRoom = sceneRoom.IsSceneConnected(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;
@@ -102,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/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..0a87d3cd0d9
--- /dev/null
+++ b/docs/authoritative-portable-experiences.md
@@ -0,0 +1,246 @@
+# 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
+
+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. |
+| `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 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
+
+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.** `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
+
+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 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
+
+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` 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.
+
+## 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.
+
+## 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