@el4cteo/rbx-studio-mcp 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,552 +1,636 @@
1
- --!strict
2
- --[[
3
- rbx-studio -- plugin entry point.
4
-
5
- Owns the toolbar UI, this window's Studio identity, and the command loop.
6
- Handlers do the actual work; this file only wires them to the transport and
7
- reports what is happening to the console widget.
8
- ]]
9
-
10
- local HttpService = game:GetService("HttpService")
11
- local RunService = game:GetService("RunService")
12
-
13
- local Config = require(script.Config)
14
- local Console = require(script.Console)
15
- local Dispatch = require(script.Dispatch)
16
- local LogBuffer = require(script.LogBuffer)
17
- local Mirror = require(script.Mirror)
18
- local Phrase = require(script.Phrase)
19
- local Themes = require(script.Themes)
20
- local Transport = require(script.Transport)
21
- local Debug = require(script.handlers.Debug)
22
- local Assets = require(script.handlers.Assets)
23
- local Capture = require(script.handlers.Capture)
24
- local Character = require(script.handlers.Character)
25
- local Geometry = require(script.handlers.Geometry)
26
- local World = require(script.handlers.World)
27
- local Discover = require(script.handlers.Discover)
28
- local Exec = require(script.handlers.Exec)
29
- local Instances = require(script.handlers.Instances)
30
- local Perf = require(script.handlers.Perf)
31
- local Playtest = require(script.handlers.Playtest)
32
- local Viewport = require(script.handlers.Viewport)
33
- local Input = require(script.handlers.Input)
34
- local Device = require(script.handlers.Device)
35
- local Api = require(script.handlers.Api)
36
- local Scripts = require(script.handlers.Scripts)
37
- local Session = require(script.handlers.Session)
38
-
39
- local SETTING_PORT = "port"
40
- local SETTING_AUTOCONNECT = "autoConnect"
41
- local SETTING_FORCE_POLL = "forcePoll"
42
- local SETTING_THEME = "theme"
43
- local SETTING_WIDGET_OPEN = "widgetOpen"
44
- local SETTING_WIDGET_SIZE = "widgetSize"
45
-
46
- --[[
47
- A fresh id per plugin load, which in practice means one per Studio window.
48
-
49
- This was originally persisted with `plugin:SetSetting`, on the reasoning that a
50
- stable id keeps reconnects mapping to the same session. That is wrong as soon
51
- as the user opens a second window: plugin settings live in one file shared by
52
- every Studio process, so both windows announce the same id, and the server
53
- treats the second connection as the first one reconnecting -- closing the
54
- original stream and making it impossible to address the two places separately.
55
-
56
- Held in memory instead. A reconnect within one load (the SSE stream hits its
57
- 30-minute cap) reuses this id, and `plugin.Unloading` detaches cleanly on
58
- reload, so ghost entries do not accumulate.
59
- ]]
60
- local SESSION_ID = HttpService:GenerateGUID(false)
61
-
62
- local function studioId(): string
63
- return SESSION_ID
64
- end
65
-
66
- --[[
67
- Whether this copy of the plugin can reach the bridge at all.
68
-
69
- Pressing Play loads the plugin into the playtest's DataModels as well as the
70
- editor's, and HttpService refuses every request from a client one: "Http
71
- requests can only be executed by game server". The transport read that as a
72
- dropped connection and retried forever, filling the console with red while
73
- nothing was actually wrong.
74
-
75
- The editor session and the play session's server can both connect and are
76
- worth connecting -- addressing a running server is useful. The client half
77
- simply says so once and stops.
78
- ]]
79
- local function canConnect(): (boolean, string?)
80
- if RunService:IsEdit() or RunService:IsServer() then
81
- return true, nil
82
- end
83
- --[[
84
- The wording names the fix, because the symptom is indistinguishable
85
- from a broken server: a user watching this window during a playtest
86
- sees a console that logs nothing while tools plainly work, and has no
87
- way to guess that the activity is in a different view of the same
88
- Studio. Reported once here and again as the strip's caption, since
89
- one line scrolled off the top is easy to miss.
90
- ]]
91
- return false,
92
- "client view of a playtest -- Studio forbids client sessions from making HTTP "
93
- .. "requests, so this panel cannot reach the bridge itself. It is MIRRORING "
94
- .. "the playtest's server session instead, so the log and the strip below "
95
- .. "are live. Switch to the Server view (Test tab, Current: Server) for the "
96
- .. "session that is actually connected."
97
- end
98
-
99
- -- First thing, before any handler or the transport can log: the buffer only
100
- -- holds what was printed after it subscribed, so every line ahead of this call
101
- -- is unrecoverable. This runs even in a client session that will never connect,
102
- -- since the console tool reads it and connectivity is a separate question.
103
- LogBuffer.start()
104
-
105
- local storedPort = plugin:GetSetting(SETTING_PORT)
106
- if typeof(storedPort) == "number" then
107
- Config.setPort(storedPort)
108
- end
109
-
110
- Transport.setForcePoll(plugin:GetSetting(SETTING_FORCE_POLL) == true)
111
-
112
- --[[
113
- The console's colour preset, restored before anything is drawn.
114
-
115
- Applied here rather than after mounting so the panel is built in the right
116
- palette from the start -- restoring it afterwards would flash the default
117
- theme for a frame on every Studio launch. An unknown id (a preset renamed,
118
- or a setting written by a newer build) falls back to the default rather than
119
- failing, which is `Themes.get`'s job.
120
- ]]
121
- local storedTheme = plugin:GetSetting(SETTING_THEME)
122
- if typeof(storedTheme) == "string" then
123
- Themes.use(storedTheme)
124
- end
125
-
126
- --[[
127
- Whether the restore above still has to be pushed into the console.
128
-
129
- `Themes.use` moves the ACTIVE ID, and anything that reads the palette live
130
- picks the change up for free -- which is why the prism cell came back on the
131
- saved preset. `Console` does not read it live: it caches the palette in an
132
- upvalue at module load, deliberately, so that forty read sites stay plain
133
- field accesses. Module load happens at the `require` above, which is BEFORE
134
- this line, so the cache held the default while the id said otherwise, and a
135
- reload came back as the saved prism drawn in the default's colours.
136
-
137
- It cannot simply be applied here either -- the console is not mounted yet.
138
- So it is remembered and applied the moment it can be, right after mounting.
139
- ]]
140
- local themeNeedsApplying = typeof(storedTheme) == "string" and Themes.activeId() == storedTheme
141
-
142
- Session.register()
143
- Discover.register()
144
- Debug.register()
145
- Instances.register()
146
- Perf.register(plugin)
147
- Playtest.register()
148
- Capture.register()
149
- Assets.register()
150
- Character.register()
151
- Geometry.register()
152
- World.register()
153
- Exec.register()
154
- Viewport.register()
155
- Scripts.register()
156
- Input.register()
157
- Device.register()
158
- Api.register()
159
-
160
- --[[
161
- The toolbar button.
162
-
163
- The icon has to be an uploaded asset: `CreateButton` takes a content string,
164
- and the only ones Studio resolves are `rbxassetid://` for uploaded images and
165
- `rbxasset://` for files that ship inside Studio itself. A path to something in
166
- this repository is not one of them, which is why the mark in `assets/` has to
167
- go through an upload before it can appear here.
168
-
169
- It used to borrow `textures/ui/common/robux.png` -- a Robux coin, sitting in
170
- the toolbar next to a plugin that has nothing to do with purchases.
171
- ]]
172
- local toolbar = plugin:CreateToolbar("rbx-studio")
173
- local button = toolbar:CreateButton(
174
- "rbx-studio",
175
- "Show the rbx-studio console",
176
- "rbxassetid://125390773465346"
177
- )
178
- button.ClickableWhenViewportHidden = true
179
-
180
- --[[
181
- The panel, opened and sized the way the user last left it -- in EVERY
182
- DataModel, which is the whole point.
183
-
184
- Pressing Play loads this plugin again into the playtest's DataModels, and a
185
- dock widget belongs to the DataModel that created it: the editor's is hidden
186
- along with the editor's view, and the playtest's is a brand new widget that
187
- Studio brings up closed and at the default size. So the panel vanished on
188
- every playtest and came back, when the user re-opened it from the toolbar,
189
- as a 560x320 float with whatever size they had chosen thrown away.
190
-
191
- Studio's own restore does not cross that boundary, so the preference is kept
192
- here instead and passed in as the INITIAL state, with `overrideEnabledRestore`
193
- set so it is honoured rather than second-guessed. Written only from the
194
- editor session -- see below -- so a playtest starting or ending can never
195
- record a decision the user did not make.
196
- ]]
197
- local storedOpen = plugin:GetSetting(SETTING_WIDGET_OPEN)
198
- local wantOpen = if typeof(storedOpen) == "boolean" then storedOpen else true
199
-
200
- local floatWidth, floatHeight = 560, 320
201
- local storedSize = plugin:GetSetting(SETTING_WIDGET_SIZE)
202
- if typeof(storedSize) == "table" then
203
- local saved = storedSize :: { [string]: any }
204
- local x, y = tonumber(saved.x), tonumber(saved.y)
205
- -- Guarded against nonsense: a zero or absurd size saved from a docked or
206
- -- mid-teardown widget would otherwise be unrecoverable without clearing
207
- -- settings by hand.
208
- if x ~= nil and y ~= nil and x >= 360 and y >= 200 and x <= 4000 and y <= 4000 then
209
- floatWidth, floatHeight = math.floor(x), math.floor(y)
210
- end
211
- end
212
-
213
- local widget = plugin:CreateDockWidgetPluginGuiAsync(
214
- "StudioMCP_Console",
215
- DockWidgetPluginGuiInfo.new(
216
- Enum.InitialDockState.Float,
217
- wantOpen,
218
- -- Override Studio's own enabled-restore: ours is the one that survives
219
- -- the hop into a playtest DataModel, and two restores disagreeing is
220
- -- what produced a panel that was open in the editor and closed in play.
221
- true,
222
- floatWidth,
223
- floatHeight,
224
- 360,
225
- 200
226
- )
227
- )
228
- widget.Title = "rbx-studio"
229
-
230
- --[[
231
- Remember what the user does with the panel, from the editor only.
232
-
233
- The editor session is the one whose Enabled and size changes are actually
234
- the user's: a playtest DataModel's widget is created, shown and destroyed by
235
- Studio around the test, and letting those transitions write would persist a
236
- "closed" the user never asked for -- reintroducing the bug through the back
237
- door.
238
- ]]
239
- if RunService:IsEdit() then
240
- local unloading = false
241
- plugin.Unloading:Connect(function()
242
- unloading = true
243
- end)
244
-
245
- widget:GetPropertyChangedSignal("Enabled"):Connect(function()
246
- if not unloading then
247
- plugin:SetSetting(SETTING_WIDGET_OPEN, widget.Enabled)
248
- end
249
- end)
250
-
251
- -- Only a real, sane size, and only while the panel is up: a hidden or
252
- -- collapsing widget reports sizes that are not a choice.
253
- local function persistSize(): boolean
254
- local size = widget.AbsoluteSize
255
- if unloading or not widget.Enabled or size.X < 360 or size.Y < 200 then
256
- return false
257
- end
258
- plugin:SetSetting(SETTING_WIDGET_SIZE, { x = math.floor(size.X), y = math.floor(size.Y) })
259
- return true
260
- end
261
-
262
- widget:GetPropertyChangedSignal("AbsoluteSize"):Connect(persistSize)
263
-
264
- --[[
265
- Seed from the size Studio has already restored, rather than waiting for
266
- a resize that may never come.
267
-
268
- Without this the setting stays empty until the user happens to drag the
269
- panel's edge, so the first playtest inherits the 560x320 default and the
270
- window visibly shrinks -- which is the same complaint as it vanishing,
271
- one step later. Studio settles the geometry a few frames after the widget
272
- is created and fires no change event for it, so it is polled briefly and
273
- then left alone.
274
- ]]
275
- task.defer(function()
276
- for _ = 1, 40 do
277
- if persistSize() then
278
- return
279
- end
280
- task.wait(0.1)
281
- end
282
- end)
283
- end
284
-
285
- local currentStatus: Transport.Status = "disconnected"
286
-
287
- local function refreshMeta()
288
- Console.setStatus(
289
- currentStatus,
290
- string.format(
291
- "127.0.0.1:%d %s build %s",
292
- Config.getPort(),
293
- Transport.getMode(),
294
- Config.BUILD_ID
295
- )
296
- )
297
- button:SetActive(currentStatus == "connected")
298
- end
299
-
300
- local connect: () -> ()
301
-
302
- Console.mount(widget, {
303
- onReconnect = function()
304
- Console.log("info", "reconnect requested")
305
- Transport.stop()
306
- task.wait(0.2)
307
- connect()
308
- end,
309
- onClear = function()
310
- Console.clear()
311
- end,
312
- onTheme = function(id)
313
- plugin:SetSetting(SETTING_THEME, id)
314
- Console.log("dim", string.format("theme: %s", id))
315
- end,
316
- })
317
-
318
- -- The saved preset, now that there is something to paint. Done immediately
319
- -- after mounting and before the first line is logged, so nothing is ever drawn
320
- -- in the wrong palette.
321
- if themeNeedsApplying then
322
- Console.applyTheme()
323
- end
324
-
325
- --[[
326
- The playtest halves, joined.
327
-
328
- The server half can reach the bridge and sees every command; the client half
329
- is the one Studio actually shows during a playtest and can reach nothing. So
330
- the server relays its console events and the client replays them, which is
331
- the only way the panel in front of the user reacts to the work being done.
332
-
333
- Note which side sets the observer: only the SERVER. The client replays
334
- events through the same `Console` functions, and if it were also observing
335
- it would echo each one straight back into the channel.
336
- ]]
337
- if Mirror.isPlaytestServer() then
338
- Mirror.startServer()
339
- Console.setObserver(function(kind, arguments)
340
- Mirror.send(kind, arguments)
341
- end)
342
- elseif Mirror.isPlaytestClient() then
343
- Console.setMirroring(true)
344
- Mirror.startClient(function(kind, arguments)
345
- if kind == "log" then
346
- local level, message, detail = arguments[1], arguments[2], arguments[3]
347
- if typeof(level) == "string" and typeof(message) == "string" then
348
- Console.log(
349
- level :: any,
350
- message,
351
- if typeof(detail) == "string" then detail else nil
352
- )
353
- end
354
- elseif kind == "beginCall" then
355
- local title, callKind = arguments[1], arguments[2]
356
- if typeof(title) == "string" and typeof(callKind) == "string" then
357
- Console.beginCall(title, callKind)
358
- end
359
- elseif kind == "recordCall" then
360
- local ok, milliseconds = arguments[1], arguments[2]
361
- if typeof(ok) == "boolean" and typeof(milliseconds) == "number" then
362
- Console.recordCall(ok, milliseconds)
363
- end
364
- end
365
- end)
366
- end
367
-
368
- Console.log("info", string.format("rbx-studio v%s", Config.PLUGIN_VERSION), "build " .. Config.BUILD_ID)
369
- Console.log("dim", string.format("place: %s (%d)", game.Name, game.PlaceId))
370
-
371
- local function onStatus(status: Transport.Status, detail: string?)
372
- local previous = currentStatus
373
- currentStatus = status
374
- refreshMeta()
375
-
376
- -- Only narrate transitions. The transport re-reports its state on every
377
- -- reconnect attempt, and echoing an unchanged status would bury real events.
378
- if status == previous and detail == nil then
379
- return
380
- end
381
-
382
- if status == "connected" then
383
- Console.log(
384
- "ok",
385
- string.format("connected to 127.0.0.1:%d", Config.getPort()),
386
- if detail then "(" .. detail .. ")" else nil
387
- )
388
- elseif status == "connecting" then
389
- Console.log("dim", string.format("connecting to 127.0.0.1:%d...", Config.getPort()))
390
- else
391
- Console.log("error", "disconnected", detail)
392
- end
393
- end
394
-
395
- --[[
396
- Runs each command on its own task. Handlers may yield -- `UpdateSourceAsync`
397
- and any `*Async` call does -- and serialising them would let one slow edit
398
- stall every other request on the stream.
399
- ]]
400
- local function onCommand(id: string, op: string, params: { [string]: any }?)
401
- task.spawn(function()
402
- local startedAt = os.clock()
403
- --[[
404
- Named for what it does, not for how it travels. `script.edit` on
405
- ServerScriptService.Systems.KillBrick reads as "Edit KillBrick",
406
- which is the thing someone watching actually wants to know; the wire
407
- name is kept alongside so the log still maps onto the protocol when
408
- something needs debugging.
409
- ]]
410
- local title = Phrase.of(op, params)
411
- --[[
412
- Announced synchronously, unlike the logging below.
413
-
414
- Deferring this looked like free latency and was not. `beginCall` sets
415
- two labels and some fields -- it never touches the RichText log, which
416
- is where the cost actually is -- and deferring it let a call that
417
- finished inside one frame record its result before the call had been
418
- announced, so the bar on the trace lost the name it was supposed to
419
- carry. A microsecond is not worth an ordering hazard.
420
- ]]
421
- Console.beginCall(title, Phrase.kindOf(op))
422
-
423
- local result = Dispatch.invoke(id, op, params)
424
- local milliseconds = (os.clock() - startedAt) * 1000
425
-
426
- --[[
427
- The answer goes out before the console hears about it.
428
-
429
- This used to be the last line of the function, which put a
430
- `table.concat` of three hundred strings, a RichText relayout of the
431
- whole log, two `Instance.new` calls and a forty-bar relayout in front
432
- of the reply on its way back to the agent. None of that is work the
433
- caller asked for, and all of it was being billed to the round trip
434
- this project measures. Drawing happens below, on time the agent is no
435
- longer waiting for.
436
- ]]
437
- Transport.sendResult(result)
438
-
439
- local elapsed = string.format("%.0fms", milliseconds)
440
- Console.recordCall(result.ok, milliseconds)
441
-
442
- if result.ok then
443
- Console.log("reply", title, elapsed)
444
- else
445
- local err = result.error
446
- Console.log(
447
- "error",
448
- string.format("%s failed: %s", title, if err then err.code else "unknown"),
449
- elapsed
450
- )
451
- if err and err.message then
452
- Console.log("dim", " " .. err.message)
453
- end
454
- end
455
- end)
456
- end
457
-
458
- --[[
459
- Bridge news that is not a command.
460
-
461
- Kept deliberately narrow: an unknown event is ignored rather than logged,
462
- because a newer server talking to an older plugin is a supported situation
463
- and "unknown event" rows would be the only symptom of it working correctly.
464
- ]]
465
- local function onEvent(event: { [string]: any })
466
- if event.event == "clients" and typeof(event.count) == "number" then
467
- Console.setClients(event.count)
468
- elseif event.event == "agent" and event.state == "finished" then
469
- Console.agentFinished()
470
- end
471
- end
472
-
473
- function connect()
474
- local allowed, reason = canConnect()
475
- if not allowed then
476
- -- Reported as standby rather than an error: nothing failed, and this
477
- -- session was never going to connect.
478
- currentStatus = "disconnected"
479
- Console.log("dim", "mirroring", reason)
480
- Console.setStatus(
481
- "mirroring",
482
- string.format("%s build %s", "client view", Config.BUILD_ID)
483
- )
484
- Console.setCaption("mirroring the playtest server")
485
- return
486
- end
487
- Transport.start(studioId(), { onCommand = onCommand, onStatus = onStatus, onEvent = onEvent })
488
- plugin:SetSetting(SETTING_AUTOCONNECT, true)
489
- end
490
-
491
- button.Click:Connect(function()
492
- widget.Enabled = not widget.Enabled
493
- end)
494
-
495
- plugin.Unloading:Connect(function()
496
- Transport.stop()
497
- end)
498
-
499
- --[[
500
- Switches this session between the push and long-poll transports.
501
-
502
- Registered here rather than in a handler module because it is the only
503
- command that has to reach back into the plugin object and the connection
504
- loop, both of which live in this file -- and registered THIS far down the
505
- file on purpose: `connect` is a forward-declared local, so a closure written
506
- above its declaration captures the global of that name instead, which is nil.
507
- That cost a session. The plugin loaded and connected perfectly, and then died
508
- the first time somebody asked it to switch transport.
509
-
510
- The reply is sent before the reconnect, and the reconnect is deferred:
511
- tearing the stream down inside the handler would strand the answer to the
512
- very call that asked for the switch, which is a confusing way to succeed.
513
- ]]
514
- Dispatch.registerAll("studio", {
515
- transport = function(params: { [string]: any }): { [string]: any }
516
- local requested = params.mode
517
- if requested ~= nil and requested ~= "sse" and requested ~= "poll" then
518
- Dispatch.fail("BAD_PARAMS", 'transport mode must be "sse" or "poll".')
519
- end
520
- if requested == nil then
521
- return { mode = Transport.getMode(), forcePoll = Transport.getForcePoll(), changed = false }
522
- end
523
-
524
- local wantPoll = requested == "poll"
525
- if Transport.getMode() == requested and Transport.getForcePoll() == wantPoll then
526
- return { mode = Transport.getMode(), forcePoll = wantPoll, changed = false }
527
- end
528
-
529
- Transport.setForcePoll(wantPoll)
530
- plugin:SetSetting(SETTING_FORCE_POLL, wantPoll)
531
- task.defer(function()
532
- Transport.stop()
533
- task.wait(0.1)
534
- connect()
535
- end)
536
- return {
537
- mode = requested,
538
- forcePoll = wantPoll,
539
- changed = true,
540
- note = "Reconnecting on the new transport; the next call will use it.",
541
- }
542
- end,
543
- })
544
-
545
- refreshMeta()
546
-
547
- -- Connect on load unless the user explicitly disconnected last session.
548
- if plugin:GetSetting(SETTING_AUTOCONNECT) ~= false then
549
- connect()
550
- else
551
- Console.log("warn", "auto-connect disabled — press reconnect to start")
552
- end
1
+ --!strict
2
+ --[[
3
+ rbx-studio -- plugin entry point.
4
+
5
+ Owns the toolbar UI, this window's Studio identity, and the command loop.
6
+ Handlers do the actual work; this file only wires them to the transport and
7
+ reports what is happening to the console widget.
8
+ ]]
9
+
10
+ local HttpService = game:GetService("HttpService")
11
+ local RunService = game:GetService("RunService")
12
+
13
+ local Config = require(script.Config)
14
+ local Console = require(script.Console)
15
+ local Dispatch = require(script.Dispatch)
16
+ local History = require(script.History)
17
+ local LogBuffer = require(script.LogBuffer)
18
+ local Mirror = require(script.Mirror)
19
+ local Phrase = require(script.Phrase)
20
+ local Themes = require(script.Themes)
21
+ local Transport = require(script.Transport)
22
+ local Debug = require(script.handlers.Debug)
23
+ local Assets = require(script.handlers.Assets)
24
+ local Capture = require(script.handlers.Capture)
25
+ local Character = require(script.handlers.Character)
26
+ local Geometry = require(script.handlers.Geometry)
27
+ local World = require(script.handlers.World)
28
+ local Discover = require(script.handlers.Discover)
29
+ local Exec = require(script.handlers.Exec)
30
+ local Instances = require(script.handlers.Instances)
31
+ local Perf = require(script.handlers.Perf)
32
+ local Playtest = require(script.handlers.Playtest)
33
+ local Viewport = require(script.handlers.Viewport)
34
+ local Input = require(script.handlers.Input)
35
+ local Device = require(script.handlers.Device)
36
+ local Api = require(script.handlers.Api)
37
+ local Scripts = require(script.handlers.Scripts)
38
+ local Session = require(script.handlers.Session)
39
+
40
+ local SETTING_PORT = "port"
41
+ local SETTING_AUTOCONNECT = "autoConnect"
42
+ local SETTING_FORCE_POLL = "forcePoll"
43
+ local SETTING_THEME = "theme"
44
+ local SETTING_WIDGET_OPEN = "widgetOpen"
45
+ local SETTING_WIDGET_SIZE = "widgetSize"
46
+
47
+ --[[
48
+ A fresh id per plugin load, which in practice means one per Studio window.
49
+
50
+ This was originally persisted with `plugin:SetSetting`, on the reasoning that a
51
+ stable id keeps reconnects mapping to the same session. That is wrong as soon
52
+ as the user opens a second window: plugin settings live in one file shared by
53
+ every Studio process, so both windows announce the same id, and the server
54
+ treats the second connection as the first one reconnecting -- closing the
55
+ original stream and making it impossible to address the two places separately.
56
+
57
+ Held in memory instead. A reconnect within one load (the SSE stream hits its
58
+ 30-minute cap) reuses this id, and `plugin.Unloading` detaches cleanly on
59
+ reload, so ghost entries do not accumulate.
60
+ ]]
61
+ local SESSION_ID = HttpService:GenerateGUID(false)
62
+
63
+ local function studioId(): string
64
+ return SESSION_ID
65
+ end
66
+
67
+ --[[
68
+ Whether this copy of the plugin can reach the bridge at all.
69
+
70
+ Pressing Play loads the plugin into the playtest's DataModels as well as the
71
+ editor's, and HttpService refuses every request from a client one: "Http
72
+ requests can only be executed by game server". The transport read that as a
73
+ dropped connection and retried forever, filling the console with red while
74
+ nothing was actually wrong.
75
+
76
+ The editor session and the play session's server can both connect and are
77
+ worth connecting -- addressing a running server is useful. The client half
78
+ simply says so once and stops.
79
+ ]]
80
+ local function canConnect(): (boolean, string?)
81
+ if RunService:IsEdit() or RunService:IsServer() then
82
+ return true, nil
83
+ end
84
+ --[[
85
+ The wording names the fix, because the symptom is indistinguishable
86
+ from a broken server: a user watching this window during a playtest
87
+ sees a console that logs nothing while tools plainly work, and has no
88
+ way to guess that the activity is in a different view of the same
89
+ Studio. Reported once here and again as the strip's caption, since
90
+ one line scrolled off the top is easy to miss.
91
+ ]]
92
+ return false,
93
+ "client view of a playtest -- Studio forbids client sessions from making HTTP "
94
+ .. "requests, so this panel cannot reach the bridge itself. It is MIRRORING "
95
+ .. "the playtest's server session instead, so the log and the strip below "
96
+ .. "are live. Switch to the Server view (Test tab, Current: Server) for the "
97
+ .. "session that is actually connected."
98
+ end
99
+
100
+ -- First thing, before any handler or the transport can log: the buffer only
101
+ -- holds what was printed after it subscribed, so every line ahead of this call
102
+ -- is unrecoverable. This runs even in a client session that will never connect,
103
+ -- since the console tool reads it and connectivity is a separate question.
104
+ LogBuffer.start()
105
+
106
+ local storedPort = plugin:GetSetting(SETTING_PORT)
107
+ if typeof(storedPort) == "number" then
108
+ Config.setPort(storedPort)
109
+ end
110
+
111
+ Transport.setForcePoll(plugin:GetSetting(SETTING_FORCE_POLL) == true)
112
+
113
+ --[[
114
+ The console's colour preset, restored before anything is drawn.
115
+
116
+ Applied here rather than after mounting so the panel is built in the right
117
+ palette from the start -- restoring it afterwards would flash the default
118
+ theme for a frame on every Studio launch. An unknown id (a preset renamed,
119
+ or a setting written by a newer build) falls back to the default rather than
120
+ failing, which is `Themes.get`'s job.
121
+ ]]
122
+ local storedTheme = plugin:GetSetting(SETTING_THEME)
123
+ if typeof(storedTheme) == "string" then
124
+ Themes.use(storedTheme)
125
+ end
126
+
127
+ --[[
128
+ Whether the restore above still has to be pushed into the console.
129
+
130
+ `Themes.use` moves the ACTIVE ID, and anything that reads the palette live
131
+ picks the change up for free -- which is why the prism cell came back on the
132
+ saved preset. `Console` does not read it live: it caches the palette in an
133
+ upvalue at module load, deliberately, so that forty read sites stay plain
134
+ field accesses. Module load happens at the `require` above, which is BEFORE
135
+ this line, so the cache held the default while the id said otherwise, and a
136
+ reload came back as the saved prism drawn in the default's colours.
137
+
138
+ It cannot simply be applied here either -- the console is not mounted yet.
139
+ So it is remembered and applied the moment it can be, right after mounting.
140
+ ]]
141
+ local themeNeedsApplying = typeof(storedTheme) == "string" and Themes.activeId() == storedTheme
142
+
143
+ Session.register()
144
+ Discover.register()
145
+ Debug.register()
146
+ Instances.register()
147
+ Perf.register(plugin)
148
+ Playtest.register()
149
+ Capture.register()
150
+ Assets.register()
151
+ Character.register()
152
+ Geometry.register()
153
+ World.register()
154
+ Exec.register()
155
+ Viewport.register()
156
+ Scripts.register()
157
+ Input.register()
158
+ Device.register()
159
+ Api.register()
160
+
161
+ --[[
162
+ The toolbar button.
163
+
164
+ The icon has to be an uploaded asset: `CreateButton` takes a content string,
165
+ and the only ones Studio resolves are `rbxassetid://` for uploaded images and
166
+ `rbxasset://` for files that ship inside Studio itself. A path to something in
167
+ this repository is not one of them, which is why the mark in `assets/` has to
168
+ go through an upload before it can appear here.
169
+
170
+ It used to borrow `textures/ui/common/robux.png` -- a Robux coin, sitting in
171
+ the toolbar next to a plugin that has nothing to do with purchases.
172
+ ]]
173
+ local toolbar = plugin:CreateToolbar("rbx-studio")
174
+ local button = toolbar:CreateButton(
175
+ "rbx-studio",
176
+ "Show the rbx-studio console",
177
+ "rbxassetid://125390773465346"
178
+ )
179
+ button.ClickableWhenViewportHidden = true
180
+
181
+ --[[
182
+ The panel, opened and sized the way the user last left it -- in EVERY
183
+ DataModel, which is the whole point.
184
+
185
+ Pressing Play loads this plugin again into the playtest's DataModels, and a
186
+ dock widget belongs to the DataModel that created it: the editor's is hidden
187
+ along with the editor's view, and the playtest's is a brand new widget that
188
+ Studio brings up closed and at the default size. So the panel vanished on
189
+ every playtest and came back, when the user re-opened it from the toolbar,
190
+ as a 560x320 float with whatever size they had chosen thrown away.
191
+
192
+ Studio's own restore does not cross that boundary, so the preference is kept
193
+ here instead and passed in as the INITIAL state, with `overrideEnabledRestore`
194
+ set so it is honoured rather than second-guessed. Written only from the
195
+ editor session -- see below -- so a playtest starting or ending can never
196
+ record a decision the user did not make.
197
+ ]]
198
+ local storedOpen = plugin:GetSetting(SETTING_WIDGET_OPEN)
199
+ local wantOpen = if typeof(storedOpen) == "boolean" then storedOpen else true
200
+
201
+ local floatWidth, floatHeight = 560, 320
202
+ local storedSize = plugin:GetSetting(SETTING_WIDGET_SIZE)
203
+ if typeof(storedSize) == "table" then
204
+ local saved = storedSize :: { [string]: any }
205
+ local x, y = tonumber(saved.x), tonumber(saved.y)
206
+ -- Guarded against nonsense: a zero or absurd size saved from a docked or
207
+ -- mid-teardown widget would otherwise be unrecoverable without clearing
208
+ -- settings by hand.
209
+ if x ~= nil and y ~= nil and x >= 360 and y >= 200 and x <= 4000 and y <= 4000 then
210
+ floatWidth, floatHeight = math.floor(x), math.floor(y)
211
+ end
212
+ end
213
+
214
+ local widget = plugin:CreateDockWidgetPluginGuiAsync(
215
+ "StudioMCP_Console",
216
+ DockWidgetPluginGuiInfo.new(
217
+ Enum.InitialDockState.Float,
218
+ wantOpen,
219
+ -- Override Studio's own enabled-restore: ours is the one that survives
220
+ -- the hop into a playtest DataModel, and two restores disagreeing is
221
+ -- what produced a panel that was open in the editor and closed in play.
222
+ true,
223
+ floatWidth,
224
+ floatHeight,
225
+ 360,
226
+ 200
227
+ )
228
+ )
229
+ widget.Title = "rbx-studio"
230
+
231
+ --[[
232
+ Remember what the user does with the panel, from the editor only.
233
+
234
+ The editor session is the one whose Enabled and size changes are actually
235
+ the user's: a playtest DataModel's widget is created, shown and destroyed by
236
+ Studio around the test, and letting those transitions write would persist a
237
+ "closed" the user never asked for -- reintroducing the bug through the back
238
+ door.
239
+ ]]
240
+ if RunService:IsEdit() then
241
+ local unloading = false
242
+ plugin.Unloading:Connect(function()
243
+ unloading = true
244
+ end)
245
+
246
+ widget:GetPropertyChangedSignal("Enabled"):Connect(function()
247
+ if not unloading then
248
+ plugin:SetSetting(SETTING_WIDGET_OPEN, widget.Enabled)
249
+ end
250
+ end)
251
+
252
+ -- Only a real, sane size, and only while the panel is up: a hidden or
253
+ -- collapsing widget reports sizes that are not a choice.
254
+ local function persistSize(): boolean
255
+ local size = widget.AbsoluteSize
256
+ if unloading or not widget.Enabled or size.X < 360 or size.Y < 200 then
257
+ return false
258
+ end
259
+ plugin:SetSetting(SETTING_WIDGET_SIZE, { x = math.floor(size.X), y = math.floor(size.Y) })
260
+ return true
261
+ end
262
+
263
+ widget:GetPropertyChangedSignal("AbsoluteSize"):Connect(persistSize)
264
+
265
+ --[[
266
+ Seed from the size Studio has already restored, rather than waiting for
267
+ a resize that may never come.
268
+
269
+ Without this the setting stays empty until the user happens to drag the
270
+ panel's edge, so the first playtest inherits the 560x320 default and the
271
+ window visibly shrinks -- which is the same complaint as it vanishing,
272
+ one step later. Studio settles the geometry a few frames after the widget
273
+ is created and fires no change event for it, so it is polled briefly and
274
+ then left alone.
275
+ ]]
276
+ task.defer(function()
277
+ for _ = 1, 40 do
278
+ if persistSize() then
279
+ return
280
+ end
281
+ task.wait(0.1)
282
+ end
283
+ end)
284
+ end
285
+
286
+ local currentStatus: Transport.Status = "disconnected"
287
+
288
+ local function refreshMeta()
289
+ Console.setStatus(
290
+ currentStatus,
291
+ string.format(
292
+ "127.0.0.1:%d %s build %s",
293
+ Config.getPort(),
294
+ Transport.getMode(),
295
+ Config.BUILD_ID
296
+ )
297
+ )
298
+ button:SetActive(currentStatus == "connected")
299
+ end
300
+
301
+ local connect: () -> ()
302
+
303
+ Console.mount(widget, {
304
+ onReconnect = function()
305
+ Console.log("info", "reconnect requested")
306
+ Transport.stop()
307
+ task.wait(0.2)
308
+ connect()
309
+ end,
310
+ onClear = function()
311
+ Console.clear()
312
+ end,
313
+ onTheme = function(id)
314
+ plugin:SetSetting(SETTING_THEME, id)
315
+ Console.log("dim", string.format("theme: %s", id))
316
+ end,
317
+ })
318
+
319
+ -- The saved preset, now that there is something to paint. Done immediately
320
+ -- after mounting and before the first line is logged, so nothing is ever drawn
321
+ -- in the wrong palette.
322
+ if themeNeedsApplying then
323
+ Console.applyTheme()
324
+ end
325
+
326
+ --[[
327
+ The log, picked up where the last copy of this plugin left it.
328
+
329
+ Restored before the banner below rather than after it, so the carried rows
330
+ read as what they are -- what was on screen a moment ago -- with this
331
+ session's first line underneath them instead of buried in the middle.
332
+
333
+ Every session reads; only the editor writes. See `History` for why.
334
+ ]]
335
+ History.attach(plugin, Console.snapshot)
336
+ local carried = History.restore()
337
+ if #carried > 0 then
338
+ Console.restore(carried)
339
+ Console.log(
340
+ "dim",
341
+ string.format("carried %d line%s over", #carried, if #carried == 1 then "" else "s"),
342
+ if RunService:IsEdit() then "from the last session" else "from the editor"
343
+ )
344
+ end
345
+
346
+ if RunService:IsEdit() then
347
+ History.start()
348
+ plugin.Unloading:Connect(function()
349
+ History.stop()
350
+ -- The loop cannot save what happens after it has stopped, and closing
351
+ -- Studio is exactly when the last few rows matter most.
352
+ History.flush()
353
+ end)
354
+ end
355
+
356
+ --[[
357
+ The playtest halves, joined.
358
+
359
+ The server half can reach the bridge and sees every command; the client half
360
+ is the one Studio actually shows during a playtest and can reach nothing. So
361
+ the server relays its console events and the client replays them, which is
362
+ the only way the panel in front of the user reacts to the work being done.
363
+
364
+ Note which side sets the observer: only the SERVER. The client replays
365
+ events through the same `Console` functions, and if it were also observing
366
+ it would echo each one straight back into the channel.
367
+ ]]
368
+ if RunService:IsEdit() then
369
+ -- The only observer the edit session needs: every logged row marks the
370
+ -- stored copy stale, and the save loop above decides when to write it.
371
+ Console.setObserver(function()
372
+ History.touch()
373
+ end)
374
+ elseif Mirror.isPlaytestServer() then
375
+ Mirror.startServer()
376
+ Console.setObserver(function(kind, arguments)
377
+ Mirror.send(kind, arguments)
378
+ end)
379
+ -- The channel itself stays shut until `onStatus` reports the connection;
380
+ -- see `Mirror.open`.
381
+ elseif Mirror.isPlaytestClient() then
382
+ Console.setMirroring(true)
383
+ Mirror.startClient(function(kind, arguments)
384
+ if kind == "log" then
385
+ local level, message, detail = arguments[1], arguments[2], arguments[3]
386
+ if typeof(level) == "string" and typeof(message) == "string" then
387
+ Console.log(
388
+ level :: any,
389
+ message,
390
+ if typeof(detail) == "string" then detail else nil
391
+ )
392
+ end
393
+ elseif kind == "beginCall" then
394
+ local title, callKind = arguments[1], arguments[2]
395
+ if typeof(title) == "string" and typeof(callKind) == "string" then
396
+ Console.beginCall(title, callKind)
397
+ end
398
+ elseif kind == "recordCall" then
399
+ local ok, milliseconds = arguments[1], arguments[2]
400
+ if typeof(ok) == "boolean" and typeof(milliseconds) == "number" then
401
+ Console.recordCall(ok, milliseconds)
402
+ end
403
+ end
404
+ end)
405
+ end
406
+
407
+ Console.log("info", string.format("rbx-studio v%s", Config.PLUGIN_VERSION), "build " .. Config.BUILD_ID)
408
+ Console.log("dim", string.format("place: %s (%d)", game.Name, game.PlaceId))
409
+
410
+ local function onStatus(status: Transport.Status, detail: string?)
411
+ local previous = currentStatus
412
+ currentStatus = status
413
+ refreshMeta()
414
+
415
+ -- Only narrate transitions. The transport re-reports its state on every
416
+ -- reconnect attempt, and echoing an unchanged status would bury real events.
417
+ if status == previous and detail == nil then
418
+ return
419
+ end
420
+
421
+ if status == "connected" then
422
+ Console.log(
423
+ "ok",
424
+ string.format("connected to 127.0.0.1:%d", Config.getPort()),
425
+ if detail then "(" .. detail .. ")" else nil
426
+ )
427
+ -- After the row, not before it: this session's own arrival is not news
428
+ -- to the client view, which announced its own a moment ago.
429
+ Mirror.open()
430
+ elseif status == "connecting" then
431
+ Console.log("dim", string.format("connecting to 127.0.0.1:%d...", Config.getPort()))
432
+ else
433
+ Console.log("error", "disconnected", detail)
434
+ end
435
+ end
436
+
437
+ --[[
438
+ Runs each command on its own task. Handlers may yield -- `UpdateSourceAsync`
439
+ and any `*Async` call does -- and serialising them would let one slow edit
440
+ stall every other request on the stream.
441
+ ]]
442
+ local function onCommand(id: string, op: string, params: { [string]: any }?)
443
+ task.spawn(function()
444
+ local startedAt = os.clock()
445
+ --[[
446
+ Named for what it does, not for how it travels. `script.edit` on
447
+ ServerScriptService.Systems.KillBrick reads as "Edit KillBrick",
448
+ which is the thing someone watching actually wants to know; the wire
449
+ name is kept alongside so the log still maps onto the protocol when
450
+ something needs debugging.
451
+ ]]
452
+ local title = Phrase.of(op, params)
453
+ --[[
454
+ Announced synchronously, unlike the logging below.
455
+
456
+ Deferring this looked like free latency and was not. `beginCall` sets
457
+ two labels and some fields -- it never touches the RichText log, which
458
+ is where the cost actually is -- and deferring it let a call that
459
+ finished inside one frame record its result before the call had been
460
+ announced, so the bar on the trace lost the name it was supposed to
461
+ carry. A microsecond is not worth an ordering hazard.
462
+ ]]
463
+ Console.beginCall(title, Phrase.kindOf(op))
464
+
465
+ local result = Dispatch.invoke(id, op, params)
466
+ local milliseconds = (os.clock() - startedAt) * 1000
467
+
468
+ --[[
469
+ The answer goes out before the console hears about it.
470
+
471
+ This used to be the last line of the function, which put a
472
+ `table.concat` of three hundred strings, a RichText relayout of the
473
+ whole log, two `Instance.new` calls and a forty-bar relayout in front
474
+ of the reply on its way back to the agent. None of that is work the
475
+ caller asked for, and all of it was being billed to the round trip
476
+ this project measures. Drawing happens below, on time the agent is no
477
+ longer waiting for.
478
+ ]]
479
+ Transport.sendResult(result)
480
+
481
+ local elapsed = string.format("%.0fms", milliseconds)
482
+ Console.recordCall(result.ok, milliseconds)
483
+
484
+ if result.ok then
485
+ Console.log("reply", title, elapsed)
486
+ else
487
+ local err = result.error
488
+ Console.log(
489
+ "error",
490
+ string.format("%s failed: %s", title, if err then err.code else "unknown"),
491
+ elapsed
492
+ )
493
+ if err and err.message then
494
+ Console.log("dim", " " .. err.message)
495
+ end
496
+ end
497
+ end)
498
+ end
499
+
500
+ --[[
501
+ Where a peer's call ran, in the width the detail column has.
502
+
503
+ The latency already lives there and only about fourteen characters fit
504
+ beside a message, so this is two letters and a space rather than "playtest
505
+ server". Which half of a playtest it was does not matter to someone reading
506
+ the editor's log afterwards; that it was not the editor does.
507
+ ]]
508
+ local function peerTag(from: any): string
509
+ if typeof(from) ~= "string" then
510
+ return "peer"
511
+ end
512
+ return if from == "edit" then "edit" else "play"
513
+ end
514
+
515
+ --[[
516
+ Bridge news that is not a command.
517
+
518
+ Kept deliberately narrow: an unknown event is ignored rather than logged,
519
+ because a newer server talking to an older plugin is a supported situation
520
+ and "unknown event" rows would be the only symptom of it working correctly.
521
+ ]]
522
+ local function onEvent(event: { [string]: any })
523
+ if event.event == "clients" and typeof(event.count) == "number" then
524
+ Console.setClients(event.count)
525
+ elseif event.event == "agent" and event.state == "finished" then
526
+ Console.agentFinished()
527
+ elseif event.event == "peer" and typeof(event.op) == "string" then
528
+ --[[
529
+ A command another session on this place just finished.
530
+
531
+ Logged as if it had run here, because as far as the user is concerned
532
+ it did: they pressed Play, watched the agent work, pressed Stop, and
533
+ the panel that comes back is this one. Without this it comes back with
534
+ a hole in it exactly the length of the playtest.
535
+
536
+ The prism is bumped too -- `beginCall` then `recordCall`, back to
537
+ back -- so the trace carries a bar for the call and the session
538
+ counters count it. Nothing here reaches the transport, so a peer row
539
+ can never be announced onwards and two sessions cannot echo forever.
540
+ ]]
541
+ local params = if typeof(event.params) == "table" then event.params else nil
542
+ local title = Phrase.of(event.op, params)
543
+ local milliseconds = if typeof(event.ms) == "number" then event.ms else 0
544
+ local ok = event.ok == true
545
+ local detail = string.format("%s %.0fms", peerTag(event.from), milliseconds)
546
+
547
+ Console.beginCall(title, Phrase.kindOf(event.op))
548
+ Console.recordCall(ok, milliseconds)
549
+ if ok then
550
+ Console.log("reply", title, detail)
551
+ else
552
+ Console.log("error", string.format("%s failed", title), detail)
553
+ end
554
+ end
555
+ end
556
+
557
+ function connect()
558
+ local allowed, reason = canConnect()
559
+ if not allowed then
560
+ -- Reported as standby rather than an error: nothing failed, and this
561
+ -- session was never going to connect.
562
+ currentStatus = "disconnected"
563
+ Console.log("dim", "mirroring", reason)
564
+ Console.setStatus(
565
+ "mirroring",
566
+ string.format("%s build %s", "client view", Config.BUILD_ID)
567
+ )
568
+ Console.setCaption("mirroring the playtest server")
569
+ return
570
+ end
571
+ Transport.start(studioId(), { onCommand = onCommand, onStatus = onStatus, onEvent = onEvent })
572
+ plugin:SetSetting(SETTING_AUTOCONNECT, true)
573
+ end
574
+
575
+ button.Click:Connect(function()
576
+ widget.Enabled = not widget.Enabled
577
+ end)
578
+
579
+ plugin.Unloading:Connect(function()
580
+ Transport.stop()
581
+ end)
582
+
583
+ --[[
584
+ Switches this session between the push and long-poll transports.
585
+
586
+ Registered here rather than in a handler module because it is the only
587
+ command that has to reach back into the plugin object and the connection
588
+ loop, both of which live in this file -- and registered THIS far down the
589
+ file on purpose: `connect` is a forward-declared local, so a closure written
590
+ above its declaration captures the global of that name instead, which is nil.
591
+ That cost a session. The plugin loaded and connected perfectly, and then died
592
+ the first time somebody asked it to switch transport.
593
+
594
+ The reply is sent before the reconnect, and the reconnect is deferred:
595
+ tearing the stream down inside the handler would strand the answer to the
596
+ very call that asked for the switch, which is a confusing way to succeed.
597
+ ]]
598
+ Dispatch.registerAll("studio", {
599
+ transport = function(params: { [string]: any }): { [string]: any }
600
+ local requested = params.mode
601
+ if requested ~= nil and requested ~= "sse" and requested ~= "poll" then
602
+ Dispatch.fail("BAD_PARAMS", 'transport mode must be "sse" or "poll".')
603
+ end
604
+ if requested == nil then
605
+ return { mode = Transport.getMode(), forcePoll = Transport.getForcePoll(), changed = false }
606
+ end
607
+
608
+ local wantPoll = requested == "poll"
609
+ if Transport.getMode() == requested and Transport.getForcePoll() == wantPoll then
610
+ return { mode = Transport.getMode(), forcePoll = wantPoll, changed = false }
611
+ end
612
+
613
+ Transport.setForcePoll(wantPoll)
614
+ plugin:SetSetting(SETTING_FORCE_POLL, wantPoll)
615
+ task.defer(function()
616
+ Transport.stop()
617
+ task.wait(0.1)
618
+ connect()
619
+ end)
620
+ return {
621
+ mode = requested,
622
+ forcePoll = wantPoll,
623
+ changed = true,
624
+ note = "Reconnecting on the new transport; the next call will use it.",
625
+ }
626
+ end,
627
+ })
628
+
629
+ refreshMeta()
630
+
631
+ -- Connect on load unless the user explicitly disconnected last session.
632
+ if plugin:GetSetting(SETTING_AUTOCONNECT) ~= false then
633
+ connect()
634
+ else
635
+ Console.log("warn", "auto-connect disabled — press reconnect to start")
636
+ end