@el4cteo/rbx-studio-mcp 0.5.6 → 0.5.9

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,829 +1,832 @@
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
- local Selection = game:GetService("Selection")
13
-
14
- local Commands = require(script.Commands)
15
- local Config = require(script.Config)
16
- local Console = require(script.Console)
17
- local Dispatch = require(script.Dispatch)
18
- local History = require(script.History)
19
- local LogBuffer = require(script.LogBuffer)
20
- local Mirror = require(script.Mirror)
21
- local Phrase = require(script.Phrase)
22
- local ScriptEdit = require(script.ScriptEdit)
23
- local Themes = require(script.Themes)
24
- local Transport = require(script.Transport)
25
- local Debug = require(script.handlers.Debug)
26
- local Assets = require(script.handlers.Assets)
27
- local Capture = require(script.handlers.Capture)
28
- local Character = require(script.handlers.Character)
29
- local Geometry = require(script.handlers.Geometry)
30
- local Generate = require(script.handlers.Generate)
31
- local Terrain = require(script.handlers.Terrain)
32
- local World = require(script.handlers.World)
33
- local Discover = require(script.handlers.Discover)
34
- local Exec = require(script.handlers.Exec)
35
- local Instances = require(script.handlers.Instances)
36
- local Perf = require(script.handlers.Perf)
37
- local Playtest = require(script.handlers.Playtest)
38
- local Viewport = require(script.handlers.Viewport)
39
- local Input = require(script.handlers.Input)
40
- local Device = require(script.handlers.Device)
41
- local Api = require(script.handlers.Api)
42
- local Scripts = require(script.handlers.Scripts)
43
- local Session = require(script.handlers.Session)
44
-
45
- local SETTING_PORT = "port"
46
- local SETTING_AUTOCONNECT = "autoConnect"
47
- local SETTING_FORCE_POLL = "forcePoll"
48
- local SETTING_THEME = "theme"
49
- local SETTING_WIDGET_OPEN = "widgetOpen"
50
- local SETTING_WIDGET_SIZE = "widgetSize"
51
- local SETTING_WIDGET_TRACE = "widgetTrace"
52
-
53
- -- Enough to cover a close and the launch after it, without growing forever.
54
- local TRACE_LIMIT = 24
55
-
56
-
57
- --[[
58
- A fresh id per plugin load, which in practice means one per Studio window.
59
-
60
- This was originally persisted with `plugin:SetSetting`, on the reasoning that a
61
- stable id keeps reconnects mapping to the same session. That is wrong as soon
62
- as the user opens a second window: plugin settings live in one file shared by
63
- every Studio process, so both windows announce the same id, and the server
64
- treats the second connection as the first one reconnecting -- closing the
65
- original stream and making it impossible to address the two places separately.
66
-
67
- Held in memory instead. A reconnect within one load (the SSE stream hits its
68
- 30-minute cap) reuses this id, and `plugin.Unloading` detaches cleanly on
69
- reload, so ghost entries do not accumulate.
70
- ]]
71
- local SESSION_ID = HttpService:GenerateGUID(false)
72
-
73
- local function studioId(): string
74
- return SESSION_ID
75
- end
76
-
77
- --[[
78
- Whether this copy of the plugin can reach the bridge at all.
79
-
80
- Pressing Play loads the plugin into the playtest's DataModels as well as the
81
- editor's, and HttpService refuses every request from a client one: "Http
82
- requests can only be executed by game server". The transport read that as a
83
- dropped connection and retried forever, filling the console with red while
84
- nothing was actually wrong.
85
-
86
- The editor session and the play session's server can both connect and are
87
- worth connecting -- addressing a running server is useful. The client half
88
- simply says so once and stops.
89
- ]]
90
- local function canConnect(): (boolean, string?)
91
- if RunService:IsEdit() or RunService:IsServer() then
92
- return true, nil
93
- end
94
- --[[
95
- The wording names the fix, because the symptom is indistinguishable
96
- from a broken server: a user watching this window during a playtest
97
- sees a console that logs nothing while tools plainly work, and has no
98
- way to guess that the activity is in a different view of the same
99
- Studio. Reported once here and again as the strip's caption, since
100
- one line scrolled off the top is easy to miss.
101
- ]]
102
- return false,
103
- "client view of a playtest -- Studio forbids client sessions from making HTTP "
104
- .. "requests, so this panel cannot reach the bridge itself. It is MIRRORING "
105
- .. "the playtest's server session instead, so the log and the strip below "
106
- .. "are live. Switch to the Server view (Test tab, Current: Server) for the "
107
- .. "session that is actually connected."
108
- end
109
-
110
- -- First thing, before any handler or the transport can log: the buffer only
111
- -- holds what was printed after it subscribed, so every line ahead of this call
112
- -- is unrecoverable. This runs even in a client session that will never connect,
113
- -- since the console tool reads it and connectivity is a separate question.
114
- LogBuffer.start()
115
-
116
- local storedPort = plugin:GetSetting(SETTING_PORT)
117
- if typeof(storedPort) == "number" then
118
- Config.setPort(storedPort)
119
- end
120
-
121
- Transport.setForcePoll(plugin:GetSetting(SETTING_FORCE_POLL) == true)
122
-
123
- --[[
124
- The console's colour preset, restored before anything is drawn.
125
-
126
- Applied here rather than after mounting so the panel is built in the right
127
- palette from the start -- restoring it afterwards would flash the default
128
- theme for a frame on every Studio launch. An unknown id (a preset renamed,
129
- or a setting written by a newer build) falls back to the default rather than
130
- failing, which is `Themes.get`'s job.
131
- ]]
132
- local storedTheme = plugin:GetSetting(SETTING_THEME)
133
- if typeof(storedTheme) == "string" then
134
- Themes.use(storedTheme)
135
- end
136
-
137
- --[[
138
- Whether the restore above still has to be pushed into the console.
139
-
140
- `Themes.use` moves the ACTIVE ID, and anything that reads the palette live
141
- picks the change up for free -- which is why the prism cell came back on the
142
- saved preset. `Console` does not read it live: it caches the palette in an
143
- upvalue at module load, deliberately, so that forty read sites stay plain
144
- field accesses. Module load happens at the `require` above, which is BEFORE
145
- this line, so the cache held the default while the id said otherwise, and a
146
- reload came back as the saved prism drawn in the default's colours.
147
-
148
- It cannot simply be applied here either -- the console is not mounted yet.
149
- So it is remembered and applied the moment it can be, right after mounting.
150
- ]]
151
- local themeNeedsApplying = typeof(storedTheme) == "string" and Themes.activeId() == storedTheme
152
-
153
- Session.register()
154
- Discover.register()
155
- Debug.register()
156
- Instances.register()
157
- Perf.register(plugin)
158
- Playtest.register()
159
- Capture.register()
160
- Assets.register()
161
- Character.register()
162
- Geometry.register()
163
- Generate.register()
164
- Terrain.register()
165
- World.register()
166
- Exec.register()
167
- Viewport.register()
168
- Scripts.register()
169
- Input.register()
170
- Device.register()
171
- Api.register()
172
-
173
- --[[
174
- The toolbar button.
175
-
176
- The icon has to be an uploaded asset: `CreateButton` takes a content string,
177
- and the only ones Studio resolves are `rbxassetid://` for uploaded images and
178
- `rbxasset://` for files that ship inside Studio itself. A path to something in
179
- this repository is not one of them, which is why the mark in `assets/` has to
180
- go through an upload before it can appear here.
181
-
182
- It used to borrow `textures/ui/common/robux.png` -- a Robux coin, sitting in
183
- the toolbar next to a plugin that has nothing to do with purchases.
184
- ]]
185
- local toolbar = plugin:CreateToolbar("rbx-studio")
186
- local button = toolbar:CreateButton(
187
- "rbx-studio",
188
- "Show the rbx-studio console",
189
- "rbxassetid://125390773465346"
190
- )
191
- button.ClickableWhenViewportHidden = true
192
-
193
- --[[
194
- The panel, opened and sized the way the user last left it -- in EVERY
195
- DataModel, which is the whole point.
196
-
197
- Pressing Play loads this plugin again into the playtest's DataModels, and a
198
- dock widget belongs to the DataModel that created it: the editor's is hidden
199
- along with the editor's view, and the playtest's is a brand new widget that
200
- Studio brings up closed and at the default size. So the panel vanished on
201
- every playtest and came back, when the user re-opened it from the toolbar,
202
- as a 560x320 float with whatever size they had chosen thrown away.
203
-
204
- Studio's own restore does not cross that boundary, so the preference is kept
205
- here instead and passed in as the INITIAL state, with `overrideEnabledRestore`
206
- set so it is honoured rather than second-guessed. Written only from the
207
- editor session -- see below -- so a playtest starting or ending can never
208
- record a decision the user did not make.
209
- ]]
210
- local storedOpen = plugin:GetSetting(SETTING_WIDGET_OPEN)
211
- local wantOpen = if typeof(storedOpen) == "boolean" then storedOpen else true
212
-
213
- local floatWidth, floatHeight = 560, 320
214
- local storedSize = plugin:GetSetting(SETTING_WIDGET_SIZE)
215
- if typeof(storedSize) == "table" then
216
- local saved = storedSize :: { [string]: any }
217
- local x, y = tonumber(saved.x), tonumber(saved.y)
218
- -- Guarded against nonsense: a zero or absurd size saved from a docked or
219
- -- mid-teardown widget would otherwise be unrecoverable without clearing
220
- -- settings by hand.
221
- if x ~= nil and y ~= nil and x >= 360 and y >= 200 and x <= 4000 and y <= 4000 then
222
- floatWidth, floatHeight = math.floor(x), math.floor(y)
223
- end
224
- end
225
-
226
- local widget = plugin:CreateDockWidgetPluginGuiAsync(
227
- "StudioMCP_Console",
228
- DockWidgetPluginGuiInfo.new(
229
- Enum.InitialDockState.Float,
230
- wantOpen,
231
- -- Override Studio's own enabled-restore: ours is the one that survives
232
- -- the hop into a playtest DataModel, and two restores disagreeing is
233
- -- what produced a panel that was open in the editor and closed in play.
234
- true,
235
- floatWidth,
236
- floatHeight,
237
- 360,
238
- 200
239
- )
240
- )
241
- widget.Title = "rbx-studio"
242
-
243
- --[[
244
- Remember what the user does with the panel, from the editor only.
245
-
246
- The editor session is the one whose Enabled and size changes are actually
247
- the user's: a playtest DataModel's widget is created, shown and destroyed by
248
- Studio around the test, and letting those transitions write would persist a
249
- "closed" the user never asked for -- reintroducing the bug through the back
250
- door.
251
- ]]
252
- --[[
253
- Whether the panel is being closed BY THE USER, as opposed to by Studio.
254
-
255
- Set only for the moment the toolbar button toggles the widget, which is the
256
- one close this plugin can attribute with certainty.
257
- ]]
258
- local closingByHand = false
259
-
260
- --[[
261
- A breadcrumb trail across sessions, so this stops being guesswork.
262
-
263
- Two fixes for "the panel does not open any more" were shipped on inference
264
- rather than observation, and this is what finally settled it. Closing a place
265
- traces as:
266
-
267
- load open=true / enabled=false / plugin.Unloading / widget.Destroying
268
-
269
- all within the same second. So both teardown signals DO fire, and promptly --
270
- the opposite of what the second fix was written to work around.
271
-
272
- The reason that fix looked like it failed is duller and worth remembering: a
273
- `false` written by an older build is sticky. Nothing writes `true` again
274
- until the panel is opened by hand, so a stored close outlives the bug that
275
- stored it and makes the next fix look broken too.
276
-
277
- The trail is kept because this bug has now been misdiagnosed twice, and
278
- twenty-four rows is a cheap price for never having to guess at it again. It
279
- also shows the X could be honoured through Unloading -- deliberately not
280
- done, since attribution cannot regress and inference has twice.
281
- ]]
282
- local function trace(what: string)
283
- local ok, existing = pcall(function()
284
- return plugin:GetSetting(SETTING_WIDGET_TRACE)
285
- end)
286
- local rows: { string } = if ok and typeof(existing) == "table" then existing :: { string } else {}
287
- table.insert(rows, string.format("%d %s", os.time(), what))
288
- while #rows > TRACE_LIMIT do
289
- table.remove(rows, 1)
290
- end
291
- pcall(function()
292
- plugin:SetSetting(SETTING_WIDGET_TRACE, rows)
293
- end)
294
- end
295
-
296
- if RunService:IsEdit() then
297
- trace(string.format("load open=%s", tostring(storedOpen)))
298
-
299
- plugin.Unloading:Connect(function()
300
- trace("plugin.Unloading")
301
- end)
302
- widget.Destroying:Connect(function()
303
- trace("widget.Destroying")
304
- end)
305
-
306
- --[[
307
- Only a close this plugin performed is remembered.
308
-
309
- Opening is recorded at once -- nothing but a user opens a panel, so a
310
- `true` is always genuine. A `false` is the dangerous one, because Studio
311
- disables the widget while tearing a place down and that is
312
- indistinguishable, from in here, from the user clicking the X. Guessing
313
- wrong writes a close nobody performed, and once `false` is stored the
314
- panel never opens again until it is opened by hand -- which is exactly
315
- the bug people report, twice now after a "fix".
316
-
317
- So the guessing is over. The toolbar button is the one close this code
318
- performs itself, and it is the only one written down. Closing the panel
319
- with the X is not persisted, so it will be back on the next launch.
320
- That is a deliberate trade: a panel that reappears when you did not want
321
- it is a small annoyance, and a panel that never appears again reads as a
322
- broken plugin.
323
-
324
- The trace above is what would let the X be honoured too -- if it shows a
325
- teardown signal that reliably beats the disable, this can use it.
326
- ]]
327
- widget:GetPropertyChangedSignal("Enabled"):Connect(function()
328
- if widget.Enabled then
329
- trace("enabled=true -> save true")
330
- plugin:SetSetting(SETTING_WIDGET_OPEN, true)
331
- return
332
- end
333
- if closingByHand then
334
- trace("enabled=false byHand -> save false")
335
- plugin:SetSetting(SETTING_WIDGET_OPEN, false)
336
- return
337
- end
338
- -- Studio's doing, or the X. Either way, not written down.
339
- trace("enabled=false ignored")
340
- end)
341
-
342
- -- Only a real, sane size, and only while the panel is up: a hidden or
343
- -- collapsing widget reports sizes that are not a choice.
344
- local function persistSize(): boolean
345
- local size = widget.AbsoluteSize
346
- if unloading or not widget.Enabled or size.X < 360 or size.Y < 200 then
347
- return false
348
- end
349
- plugin:SetSetting(SETTING_WIDGET_SIZE, { x = math.floor(size.X), y = math.floor(size.Y) })
350
- return true
351
- end
352
-
353
- widget:GetPropertyChangedSignal("AbsoluteSize"):Connect(persistSize)
354
-
355
- --[[
356
- Seed from the size Studio has already restored, rather than waiting for
357
- a resize that may never come.
358
-
359
- Without this the setting stays empty until the user happens to drag the
360
- panel's edge, so the first playtest inherits the 560x320 default and the
361
- window visibly shrinks -- which is the same complaint as it vanishing,
362
- one step later. Studio settles the geometry a few frames after the widget
363
- is created and fires no change event for it, so it is polled briefly and
364
- then left alone.
365
- ]]
366
- task.defer(function()
367
- for _ = 1, 40 do
368
- if persistSize() then
369
- return
370
- end
371
- task.wait(0.1)
372
- end
373
- end)
374
- end
375
-
376
- local currentStatus: Transport.Status = "disconnected"
377
-
378
- local function refreshMeta()
379
- Console.setStatus(
380
- currentStatus,
381
- string.format(
382
- "127.0.0.1:%d %s build %s",
383
- Config.getPort(),
384
- Transport.getMode(),
385
- Config.BUILD_ID
386
- )
387
- )
388
- button:SetActive(currentStatus == "connected")
389
- end
390
-
391
- local connect: () -> ()
392
-
393
- Console.mount(widget, {
394
- onReconnect = function()
395
- Console.log("info", "reconnect requested")
396
- Transport.stop()
397
- task.wait(0.2)
398
- connect()
399
- end,
400
- onClear = function()
401
- Console.clear()
402
- end,
403
- onTheme = function(id)
404
- plugin:SetSetting(SETTING_THEME, id)
405
- Console.log("dim", string.format("theme: %s", id))
406
- end,
407
- onSubmit = function(line)
408
- Commands.run(line)
409
- end,
410
- onComplete = function(prefix)
411
- return Commands.complete(prefix)
412
- end,
413
- })
414
-
415
- --[[
416
- What the command line cannot reach on its own.
417
-
418
- `plugin:SetSetting` is unavailable inside a ModuleScript, and the transport's
419
- lifecycle lives here rather than in the console, so the handful of commands
420
- that need either are handed a function instead of a dependency.
421
- ]]
422
- Commands.setup({
423
- reconnect = function()
424
- Console.log("info", "reconnect requested")
425
- Transport.stop()
426
- task.wait(0.2)
427
- connect()
428
- end,
429
- savePort = function(value)
430
- plugin:SetSetting(SETTING_PORT, value)
431
- end,
432
- saveTheme = function(id)
433
- plugin:SetSetting(SETTING_THEME, id)
434
- end,
435
- status = function()
436
- return currentStatus
437
- end,
438
- studioId = studioId,
439
- })
440
-
441
- -- The saved preset, now that there is something to paint. Done immediately
442
- -- after mounting and before the first line is logged, so nothing is ever drawn
443
- -- in the wrong palette.
444
- if themeNeedsApplying then
445
- Console.applyTheme()
446
- end
447
-
448
- --[[
449
- Keeps the prompt row showing what Studio has selected.
450
-
451
- `Selection.SelectionChanged` is the event the panel was missing. Everything
452
- about the selection was previously available only on demand, through
453
- `studio_status`, which answers an agent's question but not the user's: they
454
- are the one typing "make this bigger", and the word is only safe to type if
455
- they can see what it means without leaving the panel.
456
-
457
- Debounced through a generation counter rather than a timer handle, the same
458
- way `recordCall` schedules its summary. Drag-selecting a group in the
459
- viewport fires this once per instance added -- dozens of times in a second
460
- -- and re-rendering a label on every one of them is work nobody sees.
461
- ]]
462
- local selectionGeneration = 0
463
-
464
- local function describeSelection(): string
465
- local picked = Selection:Get()
466
- if #picked == 0 then
467
- return ""
468
- end
469
- if #picked == 1 then
470
- -- The name alone. The class is in the Properties panel and the path is
471
- -- in the Explorer; what this line is for is confirming WHICH thing.
472
- return string.format("\u{25A3} %s", picked[1].Name)
473
- end
474
- return string.format("\u{25A3} %d selected", #picked)
475
- end
476
-
477
- local function watchSelection()
478
- Selection.SelectionChanged:Connect(function()
479
- selectionGeneration += 1
480
- local mine = selectionGeneration
481
- task.delay(0.12, function()
482
- if selectionGeneration ~= mine then
483
- return
484
- end
485
- -- Guarded: an instance can be destroyed between the event and this
486
- -- running, and reading `.Name` on it throws.
487
- local ok, text = pcall(describeSelection)
488
- Console.setSelection(if ok then text else "")
489
- end)
490
- end)
491
- Console.setSelection(describeSelection())
492
- end
493
-
494
- -- Only in the editor. A playtest session has its own Selection service that
495
- -- nobody is clicking in, and the client view cannot reach the panel at all.
496
- if RunService:IsEdit() then
497
- watchSelection()
498
- end
499
-
500
- --[[
501
- The log, picked up where the last copy of this plugin left it.
502
-
503
- Restored before the banner below rather than after it, so the carried rows
504
- read as what they are -- what was on screen a moment ago -- with this
505
- session's first line underneath them instead of buried in the middle.
506
-
507
- Every session reads; only the editor writes. See `History` for why.
508
- ]]
509
- History.attach(plugin, Console.snapshot)
510
- local carried = History.restore()
511
- if #carried > 0 then
512
- Console.restore(carried)
513
- Console.log(
514
- "dim",
515
- string.format("carried %d line%s over", #carried, if #carried == 1 then "" else "s"),
516
- if RunService:IsEdit() then "from the last session" else "from the editor"
517
- )
518
- end
519
-
520
- if RunService:IsEdit() then
521
- History.start()
522
- plugin.Unloading:Connect(function()
523
- History.stop()
524
- -- The loop cannot save what happens after it has stopped, and closing
525
- -- Studio is exactly when the last few rows matter most.
526
- History.flush()
527
- end)
528
- end
529
-
530
- --[[
531
- The playtest halves, joined.
532
-
533
- The server half can reach the bridge and sees every command; the client half
534
- is the one Studio actually shows during a playtest and can reach nothing. So
535
- the server relays its console events and the client replays them, which is
536
- the only way the panel in front of the user reacts to the work being done.
537
-
538
- Note which side sets the observer: only the SERVER. The client replays
539
- events through the same `Console` functions, and if it were also observing
540
- it would echo each one straight back into the channel.
541
- ]]
542
- if RunService:IsEdit() then
543
- -- The only observer the edit session needs: every logged row marks the
544
- -- stored copy stale, and the save loop above decides when to write it.
545
- Console.setObserver(function()
546
- History.touch()
547
- end)
548
- elseif Mirror.isPlaytestServer() then
549
- Mirror.startServer()
550
- Console.setObserver(function(kind, arguments)
551
- Mirror.send(kind, arguments)
552
- end)
553
- -- The channel itself stays shut until `onStatus` reports the connection;
554
- -- see `Mirror.open`.
555
- elseif Mirror.isPlaytestClient() then
556
- Console.setMirroring(true)
557
- Mirror.startClient(function(kind, arguments)
558
- if kind == "log" then
559
- local level, message, detail = arguments[1], arguments[2], arguments[3]
560
- if typeof(level) == "string" and typeof(message) == "string" then
561
- Console.log(
562
- level :: any,
563
- message,
564
- if typeof(detail) == "string" then detail else nil
565
- )
566
- end
567
- elseif kind == "beginCall" then
568
- local title, callKind = arguments[1], arguments[2]
569
- if typeof(title) == "string" and typeof(callKind) == "string" then
570
- Console.beginCall(title, callKind)
571
- end
572
- elseif kind == "recordCall" then
573
- local ok, milliseconds = arguments[1], arguments[2]
574
- if typeof(ok) == "boolean" and typeof(milliseconds) == "number" then
575
- Console.recordCall(ok, milliseconds)
576
- end
577
- end
578
- end)
579
- end
580
-
581
- Console.log("info", string.format("rbx-studio v%s", Config.PLUGIN_VERSION), "build " .. Config.BUILD_ID)
582
- Console.log("dim", string.format("place: %s (%d)", game.Name, game.PlaceId))
583
-
584
- local function onStatus(status: Transport.Status, detail: string?)
585
- local previous = currentStatus
586
- currentStatus = status
587
- refreshMeta()
588
-
589
- -- Only narrate transitions. The transport re-reports its state on every
590
- -- reconnect attempt, and echoing an unchanged status would bury real events.
591
- if status == previous and detail == nil then
592
- return
593
- end
594
-
595
- if status == "connected" then
596
- Console.log(
597
- "ok",
598
- string.format("connected to 127.0.0.1:%d", Config.getPort()),
599
- if detail then "(" .. detail .. ")" else nil
600
- )
601
- -- After the row, not before it: this session's own arrival is not news
602
- -- to the client view, which announced its own a moment ago.
603
- Mirror.open()
604
- elseif status == "connecting" then
605
- Console.log("dim", string.format("connecting to 127.0.0.1:%d...", Config.getPort()))
606
- else
607
- Console.log("error", "disconnected", detail)
608
- end
609
- end
610
-
611
- --[[
612
- Runs each command on its own task. Handlers may yield -- `UpdateSourceAsync`
613
- and any `*Async` call does -- and serialising them would let one slow edit
614
- stall every other request on the stream.
615
- ]]
616
- local function onCommand(id: string, op: string, params: { [string]: any }?)
617
- task.spawn(function()
618
- local startedAt = os.clock()
619
- --[[
620
- Named for what it does, not for how it travels. `script.edit` on
621
- ServerScriptService.Systems.KillBrick reads as "Edit KillBrick",
622
- which is the thing someone watching actually wants to know; the wire
623
- name is kept alongside so the log still maps onto the protocol when
624
- something needs debugging.
625
- ]]
626
- local title = Phrase.of(op, params)
627
- --[[
628
- Announced synchronously, unlike the logging below.
629
-
630
- Deferring this looked like free latency and was not. `beginCall` sets
631
- two labels and some fields -- it never touches the RichText log, which
632
- is where the cost actually is -- and deferring it let a call that
633
- finished inside one frame record its result before the call had been
634
- announced, so the bar on the trace lost the name it was supposed to
635
- carry. A microsecond is not worth an ordering hazard.
636
- ]]
637
- Console.beginCall(title, Phrase.kindOf(op))
638
-
639
- local result = Dispatch.invoke(id, op, params)
640
- local milliseconds = (os.clock() - startedAt) * 1000
641
-
642
- --[[
643
- The answer goes out before the console hears about it.
644
-
645
- This used to be the last line of the function, which put a
646
- `table.concat` of three hundred strings, a RichText relayout of the
647
- whole log, two `Instance.new` calls and a forty-bar relayout in front
648
- of the reply on its way back to the agent. None of that is work the
649
- caller asked for, and all of it was being billed to the round trip
650
- this project measures. Drawing happens below, on time the agent is no
651
- longer waiting for.
652
- ]]
653
- Transport.sendResult(result)
654
-
655
- local elapsed = string.format("%.0fms", milliseconds)
656
- Console.recordCall(result.ok, milliseconds)
657
-
658
- if result.ok then
659
- Console.log("reply", title, elapsed)
660
- else
661
- local err = result.error
662
- Console.log(
663
- "error",
664
- string.format("%s failed: %s", title, if err then err.code else "unknown"),
665
- elapsed
666
- )
667
- if err and err.message then
668
- Console.log("dim", " " .. err.message)
669
- end
670
- end
671
- end)
672
- end
673
-
674
- --[[
675
- Where a peer's call ran, in the width the detail column has.
676
-
677
- The latency already lives there and only about fourteen characters fit
678
- beside a message, so this is two letters and a space rather than "playtest
679
- server". Which half of a playtest it was does not matter to someone reading
680
- the editor's log afterwards; that it was not the editor does.
681
- ]]
682
- local function peerTag(from: any): string
683
- if typeof(from) ~= "string" then
684
- return "peer"
685
- end
686
- return if from == "edit" then "edit" else "play"
687
- end
688
-
689
- --[[
690
- Bridge news that is not a command.
691
-
692
- Kept deliberately narrow: an unknown event is ignored rather than logged,
693
- because a newer server talking to an older plugin is a supported situation
694
- and "unknown event" rows would be the only symptom of it working correctly.
695
- ]]
696
- local function onEvent(event: { [string]: any })
697
- if event.event == "clients" and typeof(event.count) == "number" then
698
- Console.setClients(event.count, if typeof(event.list) == "table" then event.list else nil)
699
- elseif event.event == "agent" and event.state == "finished" then
700
- Console.agentFinished()
701
- elseif event.event == "console" and typeof(event.message) == "string" then
702
- --[[
703
- A line from an agent the panel itself started.
704
-
705
- Pushed rather than returned because a run lasts minutes: the request
706
- that started it answered immediately with "started", and everything
707
- after that has to arrive on the stream the plugin already holds open.
708
- ]]
709
- Console.log(
710
- (if typeof(event.level) == "string" then event.level else "dim") :: any,
711
- event.message,
712
- if typeof(event.detail) == "string" then event.detail else nil
713
- )
714
- elseif event.event == "agent-run" then
715
- Console.setPromptBusy(event.state == "running")
716
- elseif event.event == "peer" and typeof(event.op) == "string" then
717
- --[[
718
- A command another session on this place just finished.
719
-
720
- Logged as if it had run here, because as far as the user is concerned
721
- it did: they pressed Play, watched the agent work, pressed Stop, and
722
- the panel that comes back is this one. Without this it comes back with
723
- a hole in it exactly the length of the playtest.
724
-
725
- The prism is bumped too -- `beginCall` then `recordCall`, back to
726
- back -- so the trace carries a bar for the call and the session
727
- counters count it. Nothing here reaches the transport, so a peer row
728
- can never be announced onwards and two sessions cannot echo forever.
729
- ]]
730
- local params = if typeof(event.params) == "table" then event.params else nil
731
- local title = Phrase.of(event.op, params)
732
- local milliseconds = if typeof(event.ms) == "number" then event.ms else 0
733
- local ok = event.ok == true
734
- local detail = string.format("%s %.0fms", peerTag(event.from), milliseconds)
735
-
736
- Console.beginCall(title, Phrase.kindOf(event.op))
737
- Console.recordCall(ok, milliseconds)
738
- if ok then
739
- Console.log("reply", title, detail)
740
- else
741
- Console.log("error", string.format("%s failed", title), detail)
742
- end
743
- end
744
- end
745
-
746
- function connect()
747
- local allowed, reason = canConnect()
748
- if not allowed then
749
- -- Reported as standby rather than an error: nothing failed, and this
750
- -- session was never going to connect.
751
- currentStatus = "disconnected"
752
- Console.log("dim", "mirroring", reason)
753
- Console.setStatus(
754
- "mirroring",
755
- string.format("%s build %s", "client view", Config.BUILD_ID)
756
- )
757
- Console.setCaption("mirroring the playtest server")
758
- return
759
- end
760
- Transport.start(studioId(), { onCommand = onCommand, onStatus = onStatus, onEvent = onEvent })
761
- plugin:SetSetting(SETTING_AUTOCONNECT, true)
762
- end
763
-
764
- button.Click:Connect(function()
765
- -- Flagged around the toggle so the Enabled handler knows this close is the
766
- -- user's, and is safe to remember. See closingByHand.
767
- closingByHand = widget.Enabled
768
- widget.Enabled = not widget.Enabled
769
- closingByHand = false
770
- end)
771
-
772
- plugin.Unloading:Connect(function()
773
- Transport.stop()
774
- end)
775
-
776
- --[[
777
- Switches this session between the push and long-poll transports.
778
-
779
- Registered here rather than in a handler module because it is the only
780
- command that has to reach back into the plugin object and the connection
781
- loop, both of which live in this file -- and registered THIS far down the
782
- file on purpose: `connect` is a forward-declared local, so a closure written
783
- above its declaration captures the global of that name instead, which is nil.
784
- That cost a session. The plugin loaded and connected perfectly, and then died
785
- the first time somebody asked it to switch transport.
786
-
787
- The reply is sent before the reconnect, and the reconnect is deferred:
788
- tearing the stream down inside the handler would strand the answer to the
789
- very call that asked for the switch, which is a confusing way to succeed.
790
- ]]
791
- Dispatch.registerAll("studio", {
792
- transport = function(params: { [string]: any }): { [string]: any }
793
- local requested = params.mode
794
- if requested ~= nil and requested ~= "sse" and requested ~= "poll" then
795
- Dispatch.fail("BAD_PARAMS", 'transport mode must be "sse" or "poll".')
796
- end
797
- if requested == nil then
798
- return { mode = Transport.getMode(), forcePoll = Transport.getForcePoll(), changed = false }
799
- end
800
-
801
- local wantPoll = requested == "poll"
802
- if Transport.getMode() == requested and Transport.getForcePoll() == wantPoll then
803
- return { mode = Transport.getMode(), forcePoll = wantPoll, changed = false }
804
- end
805
-
806
- Transport.setForcePoll(wantPoll)
807
- plugin:SetSetting(SETTING_FORCE_POLL, wantPoll)
808
- task.defer(function()
809
- Transport.stop()
810
- task.wait(0.1)
811
- connect()
812
- end)
813
- return {
814
- mode = requested,
815
- forcePoll = wantPoll,
816
- changed = true,
817
- note = "Reconnecting on the new transport; the next call will use it.",
818
- }
819
- end,
820
- })
821
-
822
- refreshMeta()
823
-
824
- -- Connect on load unless the user explicitly disconnected last session.
825
- if plugin:GetSetting(SETTING_AUTOCONNECT) ~= false then
826
- connect()
827
- else
828
- Console.log("warn", "auto-connect disabled — press reconnect to start")
829
- 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
+ local Selection = game:GetService("Selection")
13
+
14
+ local Commands = require(script.Commands)
15
+ local Config = require(script.Config)
16
+ local Console = require(script.Console)
17
+ local Dispatch = require(script.Dispatch)
18
+ local History = require(script.History)
19
+ local LogBuffer = require(script.LogBuffer)
20
+ local Mirror = require(script.Mirror)
21
+ local Phrase = require(script.Phrase)
22
+ local ScriptEdit = require(script.ScriptEdit)
23
+ local Themes = require(script.Themes)
24
+ local Transport = require(script.Transport)
25
+ local Debug = require(script.handlers.Debug)
26
+ local Assets = require(script.handlers.Assets)
27
+ local Capture = require(script.handlers.Capture)
28
+ local Character = require(script.handlers.Character)
29
+ local Geometry = require(script.handlers.Geometry)
30
+ local Generate = require(script.handlers.Generate)
31
+ local Terrain = require(script.handlers.Terrain)
32
+ local World = require(script.handlers.World)
33
+ local Discover = require(script.handlers.Discover)
34
+ local Exec = require(script.handlers.Exec)
35
+ local Instances = require(script.handlers.Instances)
36
+ local Perf = require(script.handlers.Perf)
37
+ local Playtest = require(script.handlers.Playtest)
38
+ local Viewport = require(script.handlers.Viewport)
39
+ local Input = require(script.handlers.Input)
40
+ local Device = require(script.handlers.Device)
41
+ local Api = require(script.handlers.Api)
42
+ local Scripts = require(script.handlers.Scripts)
43
+ local Session = require(script.handlers.Session)
44
+
45
+ local SETTING_PORT = "port"
46
+ local SETTING_AUTOCONNECT = "autoConnect"
47
+ local SETTING_FORCE_POLL = "forcePoll"
48
+ local SETTING_THEME = "theme"
49
+ local SETTING_WIDGET_OPEN = "widgetOpen"
50
+ local SETTING_WIDGET_SIZE = "widgetSize"
51
+ local SETTING_WIDGET_TRACE = "widgetTrace"
52
+
53
+ -- Enough to cover a close and the launch after it, without growing forever.
54
+ local TRACE_LIMIT = 24
55
+
56
+
57
+ --[[
58
+ A fresh id per plugin load, which in practice means one per Studio window.
59
+
60
+ This was originally persisted with `plugin:SetSetting`, on the reasoning that a
61
+ stable id keeps reconnects mapping to the same session. That is wrong as soon
62
+ as the user opens a second window: plugin settings live in one file shared by
63
+ every Studio process, so both windows announce the same id, and the server
64
+ treats the second connection as the first one reconnecting -- closing the
65
+ original stream and making it impossible to address the two places separately.
66
+
67
+ Held in memory instead. A reconnect within one load (the SSE stream hits its
68
+ 30-minute cap) reuses this id, and `plugin.Unloading` detaches cleanly on
69
+ reload, so ghost entries do not accumulate.
70
+ ]]
71
+ local SESSION_ID = HttpService:GenerateGUID(false)
72
+
73
+ local function studioId(): string
74
+ return SESSION_ID
75
+ end
76
+
77
+ --[[
78
+ Whether this copy of the plugin can reach the bridge at all.
79
+
80
+ Pressing Play loads the plugin into the playtest's DataModels as well as the
81
+ editor's, and HttpService refuses every request from a client one: "Http
82
+ requests can only be executed by game server". The transport read that as a
83
+ dropped connection and retried forever, filling the console with red while
84
+ nothing was actually wrong.
85
+
86
+ The editor session and the play session's server can both connect and are
87
+ worth connecting -- addressing a running server is useful. The client half
88
+ simply says so once and stops.
89
+ ]]
90
+ local function canConnect(): (boolean, string?)
91
+ if RunService:IsEdit() or RunService:IsServer() then
92
+ return true, nil
93
+ end
94
+ --[[
95
+ The wording names the fix, because the symptom is indistinguishable
96
+ from a broken server: a user watching this window during a playtest
97
+ sees a console that logs nothing while tools plainly work, and has no
98
+ way to guess that the activity is in a different view of the same
99
+ Studio. Reported once here and again as the strip's caption, since
100
+ one line scrolled off the top is easy to miss.
101
+ ]]
102
+ return false,
103
+ "client view of a playtest -- Studio forbids client sessions from making HTTP "
104
+ .. "requests, so this panel cannot reach the bridge itself. It is MIRRORING "
105
+ .. "the playtest's server session instead, so the log and the strip below "
106
+ .. "are live. Switch to the Server view (Test tab, Current: Server) for the "
107
+ .. "session that is actually connected."
108
+ end
109
+
110
+ -- First thing, before any handler or the transport can log: the buffer only
111
+ -- holds what was printed after it subscribed, so every line ahead of this call
112
+ -- is unrecoverable. This runs even in a client session that will never connect,
113
+ -- since the console tool reads it and connectivity is a separate question.
114
+ LogBuffer.start()
115
+
116
+ local storedPort = plugin:GetSetting(SETTING_PORT)
117
+ if typeof(storedPort) == "number" then
118
+ Config.setPort(storedPort)
119
+ end
120
+
121
+ Transport.setForcePoll(plugin:GetSetting(SETTING_FORCE_POLL) == true)
122
+
123
+ --[[
124
+ The console's colour preset, restored before anything is drawn.
125
+
126
+ Applied here rather than after mounting so the panel is built in the right
127
+ palette from the start -- restoring it afterwards would flash the default
128
+ theme for a frame on every Studio launch. An unknown id (a preset renamed,
129
+ or a setting written by a newer build) falls back to the default rather than
130
+ failing, which is `Themes.get`'s job.
131
+ ]]
132
+ local storedTheme = plugin:GetSetting(SETTING_THEME)
133
+ if typeof(storedTheme) == "string" then
134
+ Themes.use(storedTheme)
135
+ end
136
+
137
+ --[[
138
+ Whether the restore above still has to be pushed into the console.
139
+
140
+ `Themes.use` moves the ACTIVE ID, and anything that reads the palette live
141
+ picks the change up for free -- which is why the prism cell came back on the
142
+ saved preset. `Console` does not read it live: it caches the palette in an
143
+ upvalue at module load, deliberately, so that forty read sites stay plain
144
+ field accesses. Module load happens at the `require` above, which is BEFORE
145
+ this line, so the cache held the default while the id said otherwise, and a
146
+ reload came back as the saved prism drawn in the default's colours.
147
+
148
+ It cannot simply be applied here either -- the console is not mounted yet.
149
+ So it is remembered and applied the moment it can be, right after mounting.
150
+ ]]
151
+ local themeNeedsApplying = typeof(storedTheme) == "string" and Themes.activeId() == storedTheme
152
+
153
+ Session.register()
154
+ Discover.register()
155
+ Debug.register()
156
+ Instances.register()
157
+ Perf.register(plugin)
158
+ Playtest.register()
159
+ Capture.register()
160
+ Assets.register()
161
+ Character.register()
162
+ Geometry.register()
163
+ Generate.register()
164
+ Terrain.register()
165
+ World.register()
166
+ Exec.register()
167
+ Viewport.register()
168
+ Scripts.register()
169
+ Input.register()
170
+ Device.register()
171
+ Api.register()
172
+
173
+ --[[
174
+ The toolbar button.
175
+
176
+ The icon has to be an uploaded asset: `CreateButton` takes a content string,
177
+ and the only ones Studio resolves are `rbxassetid://` for uploaded images and
178
+ `rbxasset://` for files that ship inside Studio itself. A path to something in
179
+ this repository is not one of them, which is why the mark in `assets/` has to
180
+ go through an upload before it can appear here.
181
+
182
+ It used to borrow `textures/ui/common/robux.png` -- a Robux coin, sitting in
183
+ the toolbar next to a plugin that has nothing to do with purchases.
184
+ ]]
185
+ local toolbar = plugin:CreateToolbar("rbx-studio")
186
+ local button = toolbar:CreateButton(
187
+ "rbx-studio",
188
+ "Show the rbx-studio console",
189
+ "rbxassetid://125390773465346"
190
+ )
191
+ button.ClickableWhenViewportHidden = true
192
+
193
+ --[[
194
+ The panel, opened and sized the way the user last left it -- in EVERY
195
+ DataModel, which is the whole point.
196
+
197
+ Pressing Play loads this plugin again into the playtest's DataModels, and a
198
+ dock widget belongs to the DataModel that created it: the editor's is hidden
199
+ along with the editor's view, and the playtest's is a brand new widget that
200
+ Studio brings up closed and at the default size. So the panel vanished on
201
+ every playtest and came back, when the user re-opened it from the toolbar,
202
+ as a 560x320 float with whatever size they had chosen thrown away.
203
+
204
+ Studio's own restore does not cross that boundary, so the preference is kept
205
+ here instead and passed in as the INITIAL state, with `overrideEnabledRestore`
206
+ set so it is honoured rather than second-guessed. Written only from the
207
+ editor session -- see below -- so a playtest starting or ending can never
208
+ record a decision the user did not make.
209
+ ]]
210
+ local storedOpen = plugin:GetSetting(SETTING_WIDGET_OPEN)
211
+ local wantOpen = if typeof(storedOpen) == "boolean" then storedOpen else true
212
+
213
+ local floatWidth, floatHeight = 560, 320
214
+ local storedSize = plugin:GetSetting(SETTING_WIDGET_SIZE)
215
+ if typeof(storedSize) == "table" then
216
+ local saved = storedSize :: { [string]: any }
217
+ local x, y = tonumber(saved.x), tonumber(saved.y)
218
+ -- Guarded against nonsense: a zero or absurd size saved from a docked or
219
+ -- mid-teardown widget would otherwise be unrecoverable without clearing
220
+ -- settings by hand.
221
+ if x ~= nil and y ~= nil and x >= 360 and y >= 200 and x <= 4000 and y <= 4000 then
222
+ floatWidth, floatHeight = math.floor(x), math.floor(y)
223
+ end
224
+ end
225
+
226
+ local widget = plugin:CreateDockWidgetPluginGuiAsync(
227
+ "StudioMCP_Console",
228
+ DockWidgetPluginGuiInfo.new(
229
+ Enum.InitialDockState.Float,
230
+ wantOpen,
231
+ -- Override Studio's own enabled-restore: ours is the one that survives
232
+ -- the hop into a playtest DataModel, and two restores disagreeing is
233
+ -- what produced a panel that was open in the editor and closed in play.
234
+ true,
235
+ floatWidth,
236
+ floatHeight,
237
+ 360,
238
+ 200
239
+ )
240
+ )
241
+ widget.Title = "rbx-studio"
242
+
243
+ --[[
244
+ Remember what the user does with the panel, from the editor only.
245
+
246
+ The editor session is the one whose Enabled and size changes are actually
247
+ the user's: a playtest DataModel's widget is created, shown and destroyed by
248
+ Studio around the test, and letting those transitions write would persist a
249
+ "closed" the user never asked for -- reintroducing the bug through the back
250
+ door.
251
+ ]]
252
+ --[[
253
+ Whether the panel is being closed BY THE USER, as opposed to by Studio.
254
+
255
+ Set only for the moment the toolbar button toggles the widget, which is the
256
+ one close this plugin can attribute with certainty.
257
+ ]]
258
+ local closingByHand = false
259
+
260
+ --[[
261
+ A breadcrumb trail across sessions, so this stops being guesswork.
262
+
263
+ Two fixes for "the panel does not open any more" were shipped on inference
264
+ rather than observation, and this is what finally settled it. Closing a place
265
+ traces as:
266
+
267
+ load open=true / enabled=false / plugin.Unloading / widget.Destroying
268
+
269
+ all within the same second. So both teardown signals DO fire, and promptly --
270
+ the opposite of what the second fix was written to work around.
271
+
272
+ The reason that fix looked like it failed is duller and worth remembering: a
273
+ `false` written by an older build is sticky. Nothing writes `true` again
274
+ until the panel is opened by hand, so a stored close outlives the bug that
275
+ stored it and makes the next fix look broken too.
276
+
277
+ The trail is kept because this bug has now been misdiagnosed twice, and
278
+ twenty-four rows is a cheap price for never having to guess at it again. It
279
+ also shows the X could be honoured through Unloading -- deliberately not
280
+ done, since attribution cannot regress and inference has twice.
281
+ ]]
282
+ local function trace(what: string)
283
+ local ok, existing = pcall(function()
284
+ return plugin:GetSetting(SETTING_WIDGET_TRACE)
285
+ end)
286
+ local rows: { string } = if ok and typeof(existing) == "table" then existing :: { string } else {}
287
+ table.insert(rows, string.format("%d %s", os.time(), what))
288
+ while #rows > TRACE_LIMIT do
289
+ table.remove(rows, 1)
290
+ end
291
+ pcall(function()
292
+ plugin:SetSetting(SETTING_WIDGET_TRACE, rows)
293
+ end)
294
+ end
295
+
296
+ if RunService:IsEdit() then
297
+ trace(string.format("load open=%s", tostring(storedOpen)))
298
+
299
+ plugin.Unloading:Connect(function()
300
+ trace("plugin.Unloading")
301
+ end)
302
+ widget.Destroying:Connect(function()
303
+ trace("widget.Destroying")
304
+ end)
305
+
306
+ --[[
307
+ Only a close this plugin performed is remembered.
308
+
309
+ Opening is recorded at once -- nothing but a user opens a panel, so a
310
+ `true` is always genuine. A `false` is the dangerous one, because Studio
311
+ disables the widget while tearing a place down and that is
312
+ indistinguishable, from in here, from the user clicking the X. Guessing
313
+ wrong writes a close nobody performed, and once `false` is stored the
314
+ panel never opens again until it is opened by hand -- which is exactly
315
+ the bug people report, twice now after a "fix".
316
+
317
+ So the guessing is over. The toolbar button is the one close this code
318
+ performs itself, and it is the only one written down. Closing the panel
319
+ with the X is not persisted, so it will be back on the next launch.
320
+ That is a deliberate trade: a panel that reappears when you did not want
321
+ it is a small annoyance, and a panel that never appears again reads as a
322
+ broken plugin.
323
+
324
+ The trace above is what would let the X be honoured too -- if it shows a
325
+ teardown signal that reliably beats the disable, this can use it.
326
+ ]]
327
+ widget:GetPropertyChangedSignal("Enabled"):Connect(function()
328
+ if widget.Enabled then
329
+ trace("enabled=true -> save true")
330
+ plugin:SetSetting(SETTING_WIDGET_OPEN, true)
331
+ return
332
+ end
333
+ if closingByHand then
334
+ trace("enabled=false byHand -> save false")
335
+ plugin:SetSetting(SETTING_WIDGET_OPEN, false)
336
+ return
337
+ end
338
+ -- Studio's doing, or the X. Either way, not written down.
339
+ trace("enabled=false ignored")
340
+ end)
341
+
342
+ -- Only a real, sane size, and only while the panel is up: a hidden or
343
+ -- collapsing widget reports sizes that are not a choice.
344
+ local function persistSize(): boolean
345
+ local size = widget.AbsoluteSize
346
+ if unloading or not widget.Enabled or size.X < 360 or size.Y < 200 then
347
+ return false
348
+ end
349
+ plugin:SetSetting(SETTING_WIDGET_SIZE, { x = math.floor(size.X), y = math.floor(size.Y) })
350
+ return true
351
+ end
352
+
353
+ widget:GetPropertyChangedSignal("AbsoluteSize"):Connect(persistSize)
354
+
355
+ --[[
356
+ Seed from the size Studio has already restored, rather than waiting for
357
+ a resize that may never come.
358
+
359
+ Without this the setting stays empty until the user happens to drag the
360
+ panel's edge, so the first playtest inherits the 560x320 default and the
361
+ window visibly shrinks -- which is the same complaint as it vanishing,
362
+ one step later. Studio settles the geometry a few frames after the widget
363
+ is created and fires no change event for it, so it is polled briefly and
364
+ then left alone.
365
+ ]]
366
+ task.defer(function()
367
+ for _ = 1, 40 do
368
+ if persistSize() then
369
+ return
370
+ end
371
+ task.wait(0.1)
372
+ end
373
+ end)
374
+ end
375
+
376
+ local currentStatus: Transport.Status = "disconnected"
377
+
378
+ local function refreshMeta()
379
+ Console.setStatus(
380
+ currentStatus,
381
+ string.format(
382
+ "127.0.0.1:%d %s build %s",
383
+ Config.getPort(),
384
+ Transport.getMode(),
385
+ Config.BUILD_ID
386
+ )
387
+ )
388
+ button:SetActive(currentStatus == "connected")
389
+ end
390
+
391
+ local connect: () -> ()
392
+
393
+ Console.mount(widget, {
394
+ onReconnect = function()
395
+ Console.log("info", "reconnect requested")
396
+ Transport.stop()
397
+ task.wait(0.2)
398
+ connect()
399
+ end,
400
+ onClear = function()
401
+ Console.clear()
402
+ end,
403
+ onTheme = function(id)
404
+ plugin:SetSetting(SETTING_THEME, id)
405
+ Console.log("dim", string.format("theme: %s", id))
406
+ end,
407
+ onSubmit = function(line)
408
+ Commands.run(line)
409
+ end,
410
+ onComplete = function(prefix)
411
+ return Commands.complete(prefix)
412
+ end,
413
+ onSuggest = function(prefix)
414
+ return Commands.suggest(prefix)
415
+ end,
416
+ })
417
+
418
+ --[[
419
+ What the command line cannot reach on its own.
420
+
421
+ `plugin:SetSetting` is unavailable inside a ModuleScript, and the transport's
422
+ lifecycle lives here rather than in the console, so the handful of commands
423
+ that need either are handed a function instead of a dependency.
424
+ ]]
425
+ Commands.setup({
426
+ reconnect = function()
427
+ Console.log("info", "reconnect requested")
428
+ Transport.stop()
429
+ task.wait(0.2)
430
+ connect()
431
+ end,
432
+ savePort = function(value)
433
+ plugin:SetSetting(SETTING_PORT, value)
434
+ end,
435
+ saveTheme = function(id)
436
+ plugin:SetSetting(SETTING_THEME, id)
437
+ end,
438
+ status = function()
439
+ return currentStatus
440
+ end,
441
+ studioId = studioId,
442
+ })
443
+
444
+ -- The saved preset, now that there is something to paint. Done immediately
445
+ -- after mounting and before the first line is logged, so nothing is ever drawn
446
+ -- in the wrong palette.
447
+ if themeNeedsApplying then
448
+ Console.applyTheme()
449
+ end
450
+
451
+ --[[
452
+ Keeps the prompt row showing what Studio has selected.
453
+
454
+ `Selection.SelectionChanged` is the event the panel was missing. Everything
455
+ about the selection was previously available only on demand, through
456
+ `studio_status`, which answers an agent's question but not the user's: they
457
+ are the one typing "make this bigger", and the word is only safe to type if
458
+ they can see what it means without leaving the panel.
459
+
460
+ Debounced through a generation counter rather than a timer handle, the same
461
+ way `recordCall` schedules its summary. Drag-selecting a group in the
462
+ viewport fires this once per instance added -- dozens of times in a second
463
+ -- and re-rendering a label on every one of them is work nobody sees.
464
+ ]]
465
+ local selectionGeneration = 0
466
+
467
+ local function describeSelection(): string
468
+ local picked = Selection:Get()
469
+ if #picked == 0 then
470
+ return ""
471
+ end
472
+ if #picked == 1 then
473
+ -- The name alone. The class is in the Properties panel and the path is
474
+ -- in the Explorer; what this line is for is confirming WHICH thing.
475
+ return string.format("\u{25A3} %s", picked[1].Name)
476
+ end
477
+ return string.format("\u{25A3} %d selected", #picked)
478
+ end
479
+
480
+ local function watchSelection()
481
+ Selection.SelectionChanged:Connect(function()
482
+ selectionGeneration += 1
483
+ local mine = selectionGeneration
484
+ task.delay(0.12, function()
485
+ if selectionGeneration ~= mine then
486
+ return
487
+ end
488
+ -- Guarded: an instance can be destroyed between the event and this
489
+ -- running, and reading `.Name` on it throws.
490
+ local ok, text = pcall(describeSelection)
491
+ Console.setSelection(if ok then text else "")
492
+ end)
493
+ end)
494
+ Console.setSelection(describeSelection())
495
+ end
496
+
497
+ -- Only in the editor. A playtest session has its own Selection service that
498
+ -- nobody is clicking in, and the client view cannot reach the panel at all.
499
+ if RunService:IsEdit() then
500
+ watchSelection()
501
+ end
502
+
503
+ --[[
504
+ The log, picked up where the last copy of this plugin left it.
505
+
506
+ Restored before the banner below rather than after it, so the carried rows
507
+ read as what they are -- what was on screen a moment ago -- with this
508
+ session's first line underneath them instead of buried in the middle.
509
+
510
+ Every session reads; only the editor writes. See `History` for why.
511
+ ]]
512
+ History.attach(plugin, Console.snapshot)
513
+ local carried = History.restore()
514
+ if #carried > 0 then
515
+ Console.restore(carried)
516
+ Console.log(
517
+ "dim",
518
+ string.format("carried %d line%s over", #carried, if #carried == 1 then "" else "s"),
519
+ if RunService:IsEdit() then "from the last session" else "from the editor"
520
+ )
521
+ end
522
+
523
+ if RunService:IsEdit() then
524
+ History.start()
525
+ plugin.Unloading:Connect(function()
526
+ History.stop()
527
+ -- The loop cannot save what happens after it has stopped, and closing
528
+ -- Studio is exactly when the last few rows matter most.
529
+ History.flush()
530
+ end)
531
+ end
532
+
533
+ --[[
534
+ The playtest halves, joined.
535
+
536
+ The server half can reach the bridge and sees every command; the client half
537
+ is the one Studio actually shows during a playtest and can reach nothing. So
538
+ the server relays its console events and the client replays them, which is
539
+ the only way the panel in front of the user reacts to the work being done.
540
+
541
+ Note which side sets the observer: only the SERVER. The client replays
542
+ events through the same `Console` functions, and if it were also observing
543
+ it would echo each one straight back into the channel.
544
+ ]]
545
+ if RunService:IsEdit() then
546
+ -- The only observer the edit session needs: every logged row marks the
547
+ -- stored copy stale, and the save loop above decides when to write it.
548
+ Console.setObserver(function()
549
+ History.touch()
550
+ end)
551
+ elseif Mirror.isPlaytestServer() then
552
+ Mirror.startServer()
553
+ Console.setObserver(function(kind, arguments)
554
+ Mirror.send(kind, arguments)
555
+ end)
556
+ -- The channel itself stays shut until `onStatus` reports the connection;
557
+ -- see `Mirror.open`.
558
+ elseif Mirror.isPlaytestClient() then
559
+ Console.setMirroring(true)
560
+ Mirror.startClient(function(kind, arguments)
561
+ if kind == "log" then
562
+ local level, message, detail = arguments[1], arguments[2], arguments[3]
563
+ if typeof(level) == "string" and typeof(message) == "string" then
564
+ Console.log(
565
+ level :: any,
566
+ message,
567
+ if typeof(detail) == "string" then detail else nil
568
+ )
569
+ end
570
+ elseif kind == "beginCall" then
571
+ local title, callKind = arguments[1], arguments[2]
572
+ if typeof(title) == "string" and typeof(callKind) == "string" then
573
+ Console.beginCall(title, callKind)
574
+ end
575
+ elseif kind == "recordCall" then
576
+ local ok, milliseconds = arguments[1], arguments[2]
577
+ if typeof(ok) == "boolean" and typeof(milliseconds) == "number" then
578
+ Console.recordCall(ok, milliseconds)
579
+ end
580
+ end
581
+ end)
582
+ end
583
+
584
+ Console.log("info", string.format("rbx-studio v%s", Config.PLUGIN_VERSION), "build " .. Config.BUILD_ID)
585
+ Console.log("dim", string.format("place: %s (%d)", game.Name, game.PlaceId))
586
+
587
+ local function onStatus(status: Transport.Status, detail: string?)
588
+ local previous = currentStatus
589
+ currentStatus = status
590
+ refreshMeta()
591
+
592
+ -- Only narrate transitions. The transport re-reports its state on every
593
+ -- reconnect attempt, and echoing an unchanged status would bury real events.
594
+ if status == previous and detail == nil then
595
+ return
596
+ end
597
+
598
+ if status == "connected" then
599
+ Console.log(
600
+ "ok",
601
+ string.format("connected to 127.0.0.1:%d", Config.getPort()),
602
+ if detail then "(" .. detail .. ")" else nil
603
+ )
604
+ -- After the row, not before it: this session's own arrival is not news
605
+ -- to the client view, which announced its own a moment ago.
606
+ Mirror.open()
607
+ elseif status == "connecting" then
608
+ Console.log("dim", string.format("connecting to 127.0.0.1:%d...", Config.getPort()))
609
+ else
610
+ Console.log("error", "disconnected", detail)
611
+ end
612
+ end
613
+
614
+ --[[
615
+ Runs each command on its own task. Handlers may yield -- `UpdateSourceAsync`
616
+ and any `*Async` call does -- and serialising them would let one slow edit
617
+ stall every other request on the stream.
618
+ ]]
619
+ local function onCommand(id: string, op: string, params: { [string]: any }?)
620
+ task.spawn(function()
621
+ local startedAt = os.clock()
622
+ --[[
623
+ Named for what it does, not for how it travels. `script.edit` on
624
+ ServerScriptService.Systems.KillBrick reads as "Edit KillBrick",
625
+ which is the thing someone watching actually wants to know; the wire
626
+ name is kept alongside so the log still maps onto the protocol when
627
+ something needs debugging.
628
+ ]]
629
+ local title = Phrase.of(op, params)
630
+ --[[
631
+ Announced synchronously, unlike the logging below.
632
+
633
+ Deferring this looked like free latency and was not. `beginCall` sets
634
+ two labels and some fields -- it never touches the RichText log, which
635
+ is where the cost actually is -- and deferring it let a call that
636
+ finished inside one frame record its result before the call had been
637
+ announced, so the bar on the trace lost the name it was supposed to
638
+ carry. A microsecond is not worth an ordering hazard.
639
+ ]]
640
+ Console.beginCall(title, Phrase.kindOf(op))
641
+
642
+ local result = Dispatch.invoke(id, op, params)
643
+ local milliseconds = (os.clock() - startedAt) * 1000
644
+
645
+ --[[
646
+ The answer goes out before the console hears about it.
647
+
648
+ This used to be the last line of the function, which put a
649
+ `table.concat` of three hundred strings, a RichText relayout of the
650
+ whole log, two `Instance.new` calls and a forty-bar relayout in front
651
+ of the reply on its way back to the agent. None of that is work the
652
+ caller asked for, and all of it was being billed to the round trip
653
+ this project measures. Drawing happens below, on time the agent is no
654
+ longer waiting for.
655
+ ]]
656
+ Transport.sendResult(result)
657
+
658
+ local elapsed = string.format("%.0fms", milliseconds)
659
+ Console.recordCall(result.ok, milliseconds)
660
+
661
+ if result.ok then
662
+ Console.log("reply", title, elapsed)
663
+ else
664
+ local err = result.error
665
+ Console.log(
666
+ "error",
667
+ string.format("%s failed: %s", title, if err then err.code else "unknown"),
668
+ elapsed
669
+ )
670
+ if err and err.message then
671
+ Console.log("dim", " " .. err.message)
672
+ end
673
+ end
674
+ end)
675
+ end
676
+
677
+ --[[
678
+ Where a peer's call ran, in the width the detail column has.
679
+
680
+ The latency already lives there and only about fourteen characters fit
681
+ beside a message, so this is two letters and a space rather than "playtest
682
+ server". Which half of a playtest it was does not matter to someone reading
683
+ the editor's log afterwards; that it was not the editor does.
684
+ ]]
685
+ local function peerTag(from: any): string
686
+ if typeof(from) ~= "string" then
687
+ return "peer"
688
+ end
689
+ return if from == "edit" then "edit" else "play"
690
+ end
691
+
692
+ --[[
693
+ Bridge news that is not a command.
694
+
695
+ Kept deliberately narrow: an unknown event is ignored rather than logged,
696
+ because a newer server talking to an older plugin is a supported situation
697
+ and "unknown event" rows would be the only symptom of it working correctly.
698
+ ]]
699
+ local function onEvent(event: { [string]: any })
700
+ if event.event == "clients" and typeof(event.count) == "number" then
701
+ Console.setClients(event.count, if typeof(event.list) == "table" then event.list else nil)
702
+ elseif event.event == "agent" and event.state == "finished" then
703
+ Console.agentFinished()
704
+ elseif event.event == "console" and typeof(event.message) == "string" then
705
+ --[[
706
+ A line from an agent the panel itself started.
707
+
708
+ Pushed rather than returned because a run lasts minutes: the request
709
+ that started it answered immediately with "started", and everything
710
+ after that has to arrive on the stream the plugin already holds open.
711
+ ]]
712
+ Console.log(
713
+ (if typeof(event.level) == "string" then event.level else "dim") :: any,
714
+ event.message,
715
+ if typeof(event.detail) == "string" then event.detail else nil
716
+ )
717
+ elseif event.event == "agent-run" then
718
+ Console.setPromptBusy(event.state == "running")
719
+ elseif event.event == "peer" and typeof(event.op) == "string" then
720
+ --[[
721
+ A command another session on this place just finished.
722
+
723
+ Logged as if it had run here, because as far as the user is concerned
724
+ it did: they pressed Play, watched the agent work, pressed Stop, and
725
+ the panel that comes back is this one. Without this it comes back with
726
+ a hole in it exactly the length of the playtest.
727
+
728
+ The prism is bumped too -- `beginCall` then `recordCall`, back to
729
+ back -- so the trace carries a bar for the call and the session
730
+ counters count it. Nothing here reaches the transport, so a peer row
731
+ can never be announced onwards and two sessions cannot echo forever.
732
+ ]]
733
+ local params = if typeof(event.params) == "table" then event.params else nil
734
+ local title = Phrase.of(event.op, params)
735
+ local milliseconds = if typeof(event.ms) == "number" then event.ms else 0
736
+ local ok = event.ok == true
737
+ local detail = string.format("%s %.0fms", peerTag(event.from), milliseconds)
738
+
739
+ Console.beginCall(title, Phrase.kindOf(event.op))
740
+ Console.recordCall(ok, milliseconds)
741
+ if ok then
742
+ Console.log("reply", title, detail)
743
+ else
744
+ Console.log("error", string.format("%s failed", title), detail)
745
+ end
746
+ end
747
+ end
748
+
749
+ function connect()
750
+ local allowed, reason = canConnect()
751
+ if not allowed then
752
+ -- Reported as standby rather than an error: nothing failed, and this
753
+ -- session was never going to connect.
754
+ currentStatus = "disconnected"
755
+ Console.log("dim", "mirroring", reason)
756
+ Console.setStatus(
757
+ "mirroring",
758
+ string.format("%s build %s", "client view", Config.BUILD_ID)
759
+ )
760
+ Console.setCaption("mirroring the playtest server")
761
+ return
762
+ end
763
+ Transport.start(studioId(), { onCommand = onCommand, onStatus = onStatus, onEvent = onEvent })
764
+ plugin:SetSetting(SETTING_AUTOCONNECT, true)
765
+ end
766
+
767
+ button.Click:Connect(function()
768
+ -- Flagged around the toggle so the Enabled handler knows this close is the
769
+ -- user's, and is safe to remember. See closingByHand.
770
+ closingByHand = widget.Enabled
771
+ widget.Enabled = not widget.Enabled
772
+ closingByHand = false
773
+ end)
774
+
775
+ plugin.Unloading:Connect(function()
776
+ Transport.stop()
777
+ end)
778
+
779
+ --[[
780
+ Switches this session between the push and long-poll transports.
781
+
782
+ Registered here rather than in a handler module because it is the only
783
+ command that has to reach back into the plugin object and the connection
784
+ loop, both of which live in this file -- and registered THIS far down the
785
+ file on purpose: `connect` is a forward-declared local, so a closure written
786
+ above its declaration captures the global of that name instead, which is nil.
787
+ That cost a session. The plugin loaded and connected perfectly, and then died
788
+ the first time somebody asked it to switch transport.
789
+
790
+ The reply is sent before the reconnect, and the reconnect is deferred:
791
+ tearing the stream down inside the handler would strand the answer to the
792
+ very call that asked for the switch, which is a confusing way to succeed.
793
+ ]]
794
+ Dispatch.registerAll("studio", {
795
+ transport = function(params: { [string]: any }): { [string]: any }
796
+ local requested = params.mode
797
+ if requested ~= nil and requested ~= "sse" and requested ~= "poll" then
798
+ Dispatch.fail("BAD_PARAMS", 'transport mode must be "sse" or "poll".')
799
+ end
800
+ if requested == nil then
801
+ return { mode = Transport.getMode(), forcePoll = Transport.getForcePoll(), changed = false }
802
+ end
803
+
804
+ local wantPoll = requested == "poll"
805
+ if Transport.getMode() == requested and Transport.getForcePoll() == wantPoll then
806
+ return { mode = Transport.getMode(), forcePoll = wantPoll, changed = false }
807
+ end
808
+
809
+ Transport.setForcePoll(wantPoll)
810
+ plugin:SetSetting(SETTING_FORCE_POLL, wantPoll)
811
+ task.defer(function()
812
+ Transport.stop()
813
+ task.wait(0.1)
814
+ connect()
815
+ end)
816
+ return {
817
+ mode = requested,
818
+ forcePoll = wantPoll,
819
+ changed = true,
820
+ note = "Reconnecting on the new transport; the next call will use it.",
821
+ }
822
+ end,
823
+ })
824
+
825
+ refreshMeta()
826
+
827
+ -- Connect on load unless the user explicitly disconnected last session.
828
+ if plugin:GetSetting(SETTING_AUTOCONNECT) ~= false then
829
+ connect()
830
+ else
831
+ Console.log("warn", "auto-connect disabled — press reconnect to start")
832
+ end