@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.5

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