Skip to content

Commit a1fc162

Browse files
authored
feat: scene content stats — debug widget, creator Scene Stats panel & MCP tools (#9457)
1 parent 3c1b988 commit a1fc162

49 files changed

Lines changed: 2597 additions & 22 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Explorer/Assets/DCL/Infrastructure/Global/Dynamic/DynamicWorldContainer.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1007,7 +1007,8 @@ await MapRendererContainer
10071007
bootstrapContainer.DiagnosticsContainer,
10081008
staticContainer.InputBlock,
10091009
assetsProvisioner,
1010-
debugBuilder
1010+
debugBuilder,
1011+
staticContainer.ScenesCache
10111012
));
10121013

10131014
if (!localSceneDevelopment)

Explorer/Assets/DCL/Infrastructure/Global/StaticContainer.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ public UniTask InitializeAsync(StaticSettings settings, CancellationToken ct)
299299
new AssetsCollidersPlugin(sharedDependencies),
300300
new AvatarShapePlugin(globalWorld, componentsContainer.ComponentPoolsRegistry, launchMode, useRemoteAssetBundles),
301301
new PrimitivesRenderingPlugin(sharedDependencies),
302+
new SceneContentStatsPlugin(),
302303
new VisibilityPlugin(),
303304
new AudioSourcesPlugin(sharedDependencies, container.WebRequestsContainer.WebRequestController, container.CacheCleaner, container.assetsProvisioner),
304305
new AudioAnalysisPlugin(sharedDependencies),

Explorer/Assets/DCL/Infrastructure/SceneRunner/SceneInstanceDeps.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ public SceneInstanceDependencies(
153153
/* Pass dependencies here if they are needed by the systems */
154154
ecsWorldSharedDependencies = new ECSWorldInstanceSharedDependencies(sceneData, partitionProvider, ecsToCRDTWriter, entitiesMap,
155155
ExceptionsHandler, EntityCollidersCache, SceneStateProvider, entityEventsBuilder, ecsMultiThreadSync,
156-
systemGroupThrottler, systemsUpdateGate);
156+
systemGroupThrottler, systemsUpdateGate, RuntimeMetrics);
157157

158158
ECSWorldFacade = ecsWorldFactory.CreateWorld(new ECSWorldFactoryArgs(ecsWorldSharedDependencies, systemGroupThrottler, sceneData));
159159
CRDTWorldSynchronizer = new CRDTWorldSynchronizer(ECSWorldFacade.EcsWorld, sdkComponentsRegistry, entityFactory, entitiesMap);

Explorer/Assets/DCL/McpServer/Core/McpJsonSchema.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,21 @@ public McpJsonSchema Object(string name, McpJsonSchema schema, string? descripti
5757
return AddField(name, field, isRequired);
5858
}
5959

60+
/// <summary>Adds an array field whose items are objects described by their own <paramref name="itemSchema" /> builder.</summary>
61+
public McpJsonSchema ObjectArray(string name, McpJsonSchema itemSchema, string? description = null, bool isRequired = false)
62+
{
63+
var field = new JObject
64+
{
65+
["type"] = "array",
66+
["items"] = itemSchema.Build(),
67+
};
68+
69+
if (description != null)
70+
field["description"] = description;
71+
72+
return AddField(name, field, isRequired);
73+
}
74+
6075
/// <summary>Adds an array field whose items are all integers.</summary>
6176
public McpJsonSchema IntegerArray(string name, string? description = null, bool isRequired = false)
6277
{

Explorer/Assets/DCL/McpServer/Systems/McpServerPlugin.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ public void InjectToWorld(ref ArchSystemsWorldBuilder<Arch.Core.World> builder,
120120
.Add(screenshotTool)
121121
.Add(new GetPlayerStateTool(globalWorld, arguments.PlayerEntity, exposedCameraData, currentSceneInfo))
122122
.Add(new GetSceneStateTool(scenesCache, currentSceneInfo, loadingStatus, localSceneDevelopment))
123+
.Add(new GetSceneContentStatsTool(scenesCache))
124+
.Add(new GetSceneContentBreakdownTool(scenesCache))
125+
.Add(new GetPerformanceStatsTool(scenesCache))
123126
.Add(new GetSceneLogsTool(logBuffer))
124127
.Add(new TeleportTool(chatMessagesBus, scenesCache, loadingStatus))
125128
.Add(new MoveToTool(globalWorldActions, globalWorld, arguments.PlayerEntity))
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
using Cysharp.Threading.Tasks;
2+
using DCL.McpServer.Core;
3+
using DCL.McpServer.Tools;
4+
using DCL.Profiling;
5+
using DCL.Utilities;
6+
using ECS.SceneLifeCycle;
7+
using Newtonsoft.Json.Linq;
8+
using NSubstitute;
9+
using NUnit.Framework;
10+
using SceneRunner.Scene;
11+
using System.Threading;
12+
13+
namespace DCL.McpServer.Tests
14+
{
15+
public class GetPerformanceStatsToolShould
16+
{
17+
private IScenesCache scenesCache = null!;
18+
private ISceneFacade scene = null!;
19+
private SceneRuntimeMetrics runtimeMetrics = null!;
20+
private ReactiveProperty<ISceneFacade?> currentScene = null!;
21+
22+
[SetUp]
23+
public void Setup()
24+
{
25+
runtimeMetrics = new SceneRuntimeMetrics { TargetFps = 30 };
26+
27+
scene = Substitute.For<ISceneFacade>();
28+
scene.RuntimeMetrics.Returns(runtimeMetrics);
29+
30+
currentScene = new ReactiveProperty<ISceneFacade?>(scene);
31+
scenesCache = Substitute.For<IScenesCache>();
32+
scenesCache.CurrentScene.Returns(currentScene);
33+
}
34+
35+
[Test]
36+
public void ReportRenderStatsFromTheSampledWindow()
37+
{
38+
// Arrange — 100 frames totalling 2 s: 20 ms average spanning 10–50 ms, 3 above the hiccup bar
39+
var tool = new GetPerformanceStatsTool(scenesCache, (_, _, _) =>
40+
UniTask.FromResult(new GetPerformanceStatsTool.FrameWindow(100, 2000f, 10f, 50f, 3)));
41+
42+
// Act
43+
var structured = (JObject)Execute(tool).Payload["structuredContent"]!;
44+
45+
// Assert
46+
McpSchemaAssert.KeysMatch(tool.OutputSchema!, structured);
47+
Assert.That(structured["framesSampled"]!.Value<int>(), Is.EqualTo(100));
48+
Assert.That(structured["averageFps"]!.Value<float>(), Is.EqualTo(50f));
49+
Assert.That(structured["minFps"]!.Value<float>(), Is.EqualTo(20f));
50+
Assert.That(structured["maxFps"]!.Value<float>(), Is.EqualTo(100f));
51+
Assert.That(structured["averageFrameMs"]!.Value<float>(), Is.EqualTo(20f));
52+
Assert.That(structured["maxFrameMs"]!.Value<float>(), Is.EqualTo(50f));
53+
Assert.That(structured["hiccupFrames"]!.Value<int>(), Is.EqualTo(3));
54+
}
55+
56+
[Test]
57+
public void ReportSceneTickStatsOnlyFromTicksInsideTheWindow()
58+
{
59+
// Arrange — stale pre-window 100 ms ticks would read as 10 fps if they leaked into the window
60+
for (var i = 0; i < 10; i++)
61+
runtimeMetrics.TickTimesNs.Add(100_000_000);
62+
63+
var tool = new GetPerformanceStatsTool(scenesCache, (_, _, _) =>
64+
{
65+
for (var i = 0; i < 4; i++)
66+
runtimeMetrics.TickTimesNs.Add(25_000_000); // 40 fps during the window
67+
68+
return UniTask.FromResult(SteadyWindow());
69+
});
70+
71+
// Act
72+
var structured = (JObject)Execute(tool).Payload["structuredContent"]!;
73+
var sceneTick = (JObject)structured["sceneTick"]!;
74+
75+
// Assert
76+
McpSchemaAssert.KeysMatch(tool.OutputSchema!, structured);
77+
Assert.That(sceneTick["averageFps"]!.Value<float>(), Is.EqualTo(40f));
78+
Assert.That(sceneTick["minFps"]!.Value<float>(), Is.EqualTo(40f));
79+
Assert.That(sceneTick["maxFps"]!.Value<float>(), Is.EqualTo(40f));
80+
Assert.That(sceneTick["targetFps"]!.Value<int>(), Is.EqualTo(30));
81+
}
82+
83+
[Test]
84+
public void ReportNullSceneTickWhenNoTicksLandDuringTheWindow()
85+
{
86+
// Arrange — the ring holds only stale pre-window ticks, as when the scene is paused
87+
for (var i = 0; i < 10; i++)
88+
runtimeMetrics.TickTimesNs.Add(100_000_000);
89+
90+
var tool = new GetPerformanceStatsTool(scenesCache, (_, _, _) => UniTask.FromResult(SteadyWindow()));
91+
92+
// Act
93+
var structured = (JObject)Execute(tool).Payload["structuredContent"]!;
94+
95+
// Assert
96+
Assert.That(structured["sceneTick"]!.Type, Is.EqualTo(JTokenType.Null));
97+
}
98+
99+
[Test]
100+
public void ReportNullSceneTickWhenTheSceneChangesMidSample()
101+
{
102+
// Arrange — the current scene flips during the window, so its ticks describe a mixed window
103+
var tool = new GetPerformanceStatsTool(scenesCache, (_, _, _) =>
104+
{
105+
runtimeMetrics.TickTimesNs.Add(25_000_000);
106+
currentScene.Value = null;
107+
return UniTask.FromResult(SteadyWindow());
108+
});
109+
110+
// Act
111+
var structured = (JObject)Execute(tool).Payload["structuredContent"]!;
112+
113+
// Assert
114+
Assert.That(structured["sceneTick"]!.Type, Is.EqualTo(JTokenType.Null));
115+
}
116+
117+
[Test]
118+
public void ClampSampleSecondsBeforeSampling()
119+
{
120+
// Arrange
121+
var receivedSeconds = 0f;
122+
123+
var tool = new GetPerformanceStatsTool(scenesCache, (seconds, _, _) =>
124+
{
125+
receivedSeconds = seconds;
126+
return UniTask.FromResult(SteadyWindow());
127+
});
128+
129+
// Act
130+
var structured = (JObject)Execute(tool, new JObject { ["sampleSeconds"] = 100 }).Payload["structuredContent"]!;
131+
132+
// Assert
133+
Assert.That(receivedSeconds, Is.EqualTo(10f));
134+
Assert.That(structured["sampleSeconds"]!.Value<float>(), Is.EqualTo(10f));
135+
}
136+
137+
[Test]
138+
public void SampleHiccupsAgainstTheClientWideThreshold()
139+
{
140+
// Arrange
141+
var receivedThresholdMs = 0f;
142+
143+
var tool = new GetPerformanceStatsTool(scenesCache, (_, thresholdMs, _) =>
144+
{
145+
receivedThresholdMs = thresholdMs;
146+
return UniTask.FromResult(SteadyWindow());
147+
});
148+
149+
// Act
150+
var structured = (JObject)Execute(tool).Payload["structuredContent"]!;
151+
152+
// Assert
153+
float expected = Profiler.EffectiveHiccupThresholdNs() / 1_000_000f;
154+
Assert.That(receivedThresholdMs, Is.EqualTo(expected));
155+
Assert.That(structured["hiccupThresholdMs"], Is.Not.Null);
156+
}
157+
158+
[Test]
159+
public void ErrorWhenNoFramesWereSampled()
160+
{
161+
// Arrange
162+
var tool = new GetPerformanceStatsTool(scenesCache, (_, _, _) =>
163+
UniTask.FromResult(new GetPerformanceStatsTool.FrameWindow(0, 0f, 0f, 0f, 0)));
164+
165+
// Act
166+
McpToolResult result = Execute(tool);
167+
168+
// Assert
169+
Assert.That(result.Payload["isError"]!.Value<bool>(), Is.True);
170+
}
171+
172+
private static GetPerformanceStatsTool.FrameWindow SteadyWindow() =>
173+
new (120, 1920f, 16f, 16f, 0);
174+
175+
private static McpToolResult Execute(GetPerformanceStatsTool tool, JObject? arguments = null) =>
176+
tool.ExecuteAsync(arguments ?? new JObject(), CancellationToken.None).GetAwaiter().GetResult();
177+
}
178+
}

Explorer/Assets/DCL/McpServer/Tests/GetPerformanceStatsToolShould.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)