@el4cteo/rbx-studio-mcp 0.7.0 → 0.7.1

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