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