@el4cteo/rbx-studio-mcp 0.1.0

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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/bridge/rpc.js +243 -0
  4. package/dist/bridge/rpc.js.map +1 -0
  5. package/dist/bridge/server.js +281 -0
  6. package/dist/bridge/server.js.map +1 -0
  7. package/dist/index.js +104 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/apidump.js +269 -0
  10. package/dist/lib/apidump.js.map +1 -0
  11. package/dist/lib/errors.js +38 -0
  12. package/dist/lib/errors.js.map +1 -0
  13. package/dist/lib/format.js +191 -0
  14. package/dist/lib/format.js.map +1 -0
  15. package/dist/lib/pluginbuild.js +83 -0
  16. package/dist/lib/pluginbuild.js.map +1 -0
  17. package/dist/lib/png.js +84 -0
  18. package/dist/lib/png.js.map +1 -0
  19. package/dist/lib/protocol.js +22 -0
  20. package/dist/lib/protocol.js.map +1 -0
  21. package/dist/lib/tool.js +27 -0
  22. package/dist/lib/tool.js.map +1 -0
  23. package/dist/resources.js +70 -0
  24. package/dist/resources.js.map +1 -0
  25. package/dist/tools/api.js +78 -0
  26. package/dist/tools/api.js.map +1 -0
  27. package/dist/tools/character.js +94 -0
  28. package/dist/tools/character.js.map +1 -0
  29. package/dist/tools/debug.js +211 -0
  30. package/dist/tools/debug.js.map +1 -0
  31. package/dist/tools/device.js +74 -0
  32. package/dist/tools/device.js.map +1 -0
  33. package/dist/tools/discover.js +217 -0
  34. package/dist/tools/discover.js.map +1 -0
  35. package/dist/tools/exec.js +191 -0
  36. package/dist/tools/exec.js.map +1 -0
  37. package/dist/tools/input.js +96 -0
  38. package/dist/tools/input.js.map +1 -0
  39. package/dist/tools/instances.js +261 -0
  40. package/dist/tools/instances.js.map +1 -0
  41. package/dist/tools/perf.js +367 -0
  42. package/dist/tools/perf.js.map +1 -0
  43. package/dist/tools/playtest.js +153 -0
  44. package/dist/tools/playtest.js.map +1 -0
  45. package/dist/tools/screenshot.js +75 -0
  46. package/dist/tools/screenshot.js.map +1 -0
  47. package/dist/tools/scripts.js +316 -0
  48. package/dist/tools/scripts.js.map +1 -0
  49. package/dist/tools/session.js +152 -0
  50. package/dist/tools/session.js.map +1 -0
  51. package/dist/tools/world.js +281 -0
  52. package/dist/tools/world.js.map +1 -0
  53. package/package.json +62 -0
  54. package/plugin/default.project.json +6 -0
  55. package/plugin/src/Config.luau +59 -0
  56. package/plugin/src/Console.luau +657 -0
  57. package/plugin/src/Context.luau +35 -0
  58. package/plugin/src/Dispatch.luau +90 -0
  59. package/plugin/src/Editor.luau +142 -0
  60. package/plugin/src/Emulation.luau +151 -0
  61. package/plugin/src/LogBuffer.luau +277 -0
  62. package/plugin/src/Net.luau +102 -0
  63. package/plugin/src/Paths.luau +255 -0
  64. package/plugin/src/Phrase.luau +465 -0
  65. package/plugin/src/Png.luau +238 -0
  66. package/plugin/src/Scope.luau +78 -0
  67. package/plugin/src/ScriptEdit.luau +100 -0
  68. package/plugin/src/Serialize.luau +287 -0
  69. package/plugin/src/TextEdit.luau +296 -0
  70. package/plugin/src/Transport.luau +328 -0
  71. package/plugin/src/Undo.luau +72 -0
  72. package/plugin/src/Visuals.luau +710 -0
  73. package/plugin/src/handlers/Api.luau +242 -0
  74. package/plugin/src/handlers/Assets.luau +145 -0
  75. package/plugin/src/handlers/Capture.luau +187 -0
  76. package/plugin/src/handlers/Character.luau +361 -0
  77. package/plugin/src/handlers/Debug.luau +391 -0
  78. package/plugin/src/handlers/Device.luau +119 -0
  79. package/plugin/src/handlers/Discover.luau +289 -0
  80. package/plugin/src/handlers/Exec.luau +270 -0
  81. package/plugin/src/handlers/Geometry.luau +261 -0
  82. package/plugin/src/handlers/Input.luau +287 -0
  83. package/plugin/src/handlers/Instances.luau +389 -0
  84. package/plugin/src/handlers/Perf.luau +645 -0
  85. package/plugin/src/handlers/Playtest.luau +205 -0
  86. package/plugin/src/handlers/Scripts.luau +387 -0
  87. package/plugin/src/handlers/Session.luau +168 -0
  88. package/plugin/src/handlers/Viewport.luau +302 -0
  89. package/plugin/src/handlers/World.luau +176 -0
  90. package/plugin/src/init.server.luau +317 -0
  91. package/scripts/build-plugin.mjs +157 -0
  92. package/scripts/check-plugin.mjs +97 -0
  93. package/scripts/install-plugin.mjs +39 -0
  94. package/scripts/latency.mjs +201 -0
  95. package/scripts/locate-luau.mjs +51 -0
  96. package/scripts/sourcemap.mjs +58 -0
  97. package/scripts/test-plugin.mjs +82 -0
@@ -0,0 +1,710 @@
1
+ --!strict
2
+ --[[
3
+ The console's activity band: a turning wireframe beside a live latency trace.
4
+
5
+ Watching an agent work is mostly waiting, and the log answers "what is it
6
+ doing" precisely while answering "is it still alive" badly -- a stalled
7
+ session and a thinking one produce the same still screen.
8
+
9
+ This began as a full-panel overlay and that was wrong. It sat on top of the
10
+ log, so the thing you actually read was covered and, being slightly
11
+ transparent, softened underneath. Decoration that costs legibility is a bad
12
+ trade however good it looks. So it is a band now: a fixed strip above the
13
+ log, which keeps every line at full contrast and never moves under the
14
+ reader. The log shrinks by the band's height and nothing overlaps.
15
+
16
+ The strip earns its space twice over. The wireframe on the left says the
17
+ session is live; the trace beside it is the last forty calls, each a bar as
18
+ tall as it was slow and coloured by whether it worked. That turns "is it
19
+ alive" and "is it healthy" into one glance, which is more than the log gives
20
+ and more than an animation alone would.
21
+
22
+ The solid is a real projection, not a canned loop: vertices rotate in three
23
+ dimensions and pass through a perspective divide every frame, so near edges
24
+ are brighter and thicker because they genuinely are nearer. Roblox has no
25
+ line primitive, so each edge is a thin Frame turned about its midpoint, and
26
+ the frames are pooled -- allocating Instances inside a per-frame loop is how
27
+ a decorative strip becomes a performance complaint.
28
+ ]]
29
+
30
+ local RunService = game:GetService("RunService")
31
+
32
+ local Visuals = {}
33
+
34
+ local PHI = (1 + math.sqrt(5)) / 2
35
+
36
+ type Shape = {
37
+ name: string,
38
+ vertices: { Vector3 },
39
+ edges: { { number } },
40
+ }
41
+
42
+ --[[
43
+ Ordered by how busy they look. The session climbs this list as it works, so
44
+ the shape itself reports load: an idle plugin shows a tetrahedron, a busy one
45
+ an icosahedron.
46
+ ]]
47
+ local SHAPES: { Shape } = {
48
+ {
49
+ name = "tetrahedron",
50
+ vertices = {
51
+ Vector3.new(1, 1, 1),
52
+ Vector3.new(1, -1, -1),
53
+ Vector3.new(-1, 1, -1),
54
+ Vector3.new(-1, -1, 1),
55
+ },
56
+ edges = { { 1, 2 }, { 1, 3 }, { 1, 4 }, { 2, 3 }, { 2, 4 }, { 3, 4 } },
57
+ },
58
+ {
59
+ name = "octahedron",
60
+ vertices = {
61
+ Vector3.new(1, 0, 0),
62
+ Vector3.new(-1, 0, 0),
63
+ Vector3.new(0, 1, 0),
64
+ Vector3.new(0, -1, 0),
65
+ Vector3.new(0, 0, 1),
66
+ Vector3.new(0, 0, -1),
67
+ },
68
+ edges = {
69
+ { 1, 3 }, { 1, 4 }, { 1, 5 }, { 1, 6 },
70
+ { 2, 3 }, { 2, 4 }, { 2, 5 }, { 2, 6 },
71
+ { 3, 5 }, { 5, 4 }, { 4, 6 }, { 6, 3 },
72
+ },
73
+ },
74
+ {
75
+ name = "cube",
76
+ vertices = {
77
+ Vector3.new(-1, -1, -1), Vector3.new(1, -1, -1),
78
+ Vector3.new(1, 1, -1), Vector3.new(-1, 1, -1),
79
+ Vector3.new(-1, -1, 1), Vector3.new(1, -1, 1),
80
+ Vector3.new(1, 1, 1), Vector3.new(-1, 1, 1),
81
+ },
82
+ edges = {
83
+ { 1, 2 }, { 2, 3 }, { 3, 4 }, { 4, 1 },
84
+ { 5, 6 }, { 6, 7 }, { 7, 8 }, { 8, 5 },
85
+ { 1, 5 }, { 2, 6 }, { 3, 7 }, { 4, 8 },
86
+ },
87
+ },
88
+ {
89
+ name = "icosahedron",
90
+ vertices = {
91
+ Vector3.new(0, 1, PHI), Vector3.new(0, -1, PHI),
92
+ Vector3.new(0, 1, -PHI), Vector3.new(0, -1, -PHI),
93
+ Vector3.new(1, PHI, 0), Vector3.new(-1, PHI, 0),
94
+ Vector3.new(1, -PHI, 0), Vector3.new(-1, -PHI, 0),
95
+ Vector3.new(PHI, 0, 1), Vector3.new(PHI, 0, -1),
96
+ Vector3.new(-PHI, 0, 1), Vector3.new(-PHI, 0, -1),
97
+ },
98
+ edges = {
99
+ { 1, 2 }, { 1, 5 }, { 1, 6 }, { 1, 9 }, { 1, 11 },
100
+ { 2, 7 }, { 2, 8 }, { 2, 9 }, { 2, 11 },
101
+ { 3, 4 }, { 3, 5 }, { 3, 6 }, { 3, 10 }, { 3, 12 },
102
+ { 4, 7 }, { 4, 8 }, { 4, 10 }, { 4, 12 },
103
+ { 5, 6 }, { 5, 9 }, { 5, 10 },
104
+ { 6, 11 }, { 6, 12 },
105
+ { 7, 8 }, { 7, 9 }, { 7, 10 },
106
+ { 8, 11 }, { 8, 12 },
107
+ { 9, 10 }, { 11, 12 },
108
+ },
109
+ },
110
+ }
111
+
112
+ local MAX_EDGES = 30
113
+ local MAX_VERTICES = 12
114
+ local FOCAL = 3.4
115
+
116
+ -- The inner solid, counter-rotating inside the outer one. An octahedron
117
+ -- regardless of what is outside it: it reads as a core rather than as a second
118
+ -- object competing for attention, and its 12 edges are cheap.
119
+ local INNER_SHAPE = 2
120
+ local INNER_SCALE = 0.42
121
+
122
+ -- The strip's height, and the width reserved for the solid at its left end.
123
+ -- The solid was originally as tall as the band and read as looming: it crowded
124
+ -- the caption and made a 46px strip feel like a panel. It is an indicator, not
125
+ -- the subject, so it sits small with air around it.
126
+ Visuals.BAND_HEIGHT = 44
127
+ local CELL = 34
128
+ local GUTTER = 16
129
+
130
+ -- How many calls the trace remembers. Forty is about a screen's width of bars
131
+ -- at two pixels each, and about as far back as anyone reads a trend.
132
+ local TRACE = 40
133
+
134
+ -- Bars are scaled against this, so a normal call is a low bar and a slow one is
135
+ -- obvious. Anything past it clamps to full height rather than flattening the
136
+ -- rest of the trace into invisibility.
137
+ local SLOW_MS = 400
138
+
139
+ -- Pixels from one bar to the next. Also the width of a bar's hit target and of
140
+ -- the hover highlight, so pointing between two bars still selects one of them
141
+ -- rather than falling into a dead gap.
142
+ local BAR_STRIDE = 5
143
+
144
+ type Bar = {
145
+ frame: Frame,
146
+ --[[
147
+ An invisible column over the bar, full height of the trace, wide enough to
148
+ hit. The bar itself is three pixels wide and usually one pixel tall -- a
149
+ fast call is a stub on the baseline -- so hovering the bar is not something
150
+ a person can reliably do. The hit target is the whole column above it, so
151
+ pointing anywhere near a bar selects it.
152
+ ]]
153
+ hit: TextButton,
154
+ milliseconds: number,
155
+ ok: boolean,
156
+ }
157
+
158
+ type Runtime = {
159
+ band: Frame?,
160
+ cell: Frame?,
161
+ trace: Frame?,
162
+ highlight: Frame?,
163
+ readout: TextLabel?,
164
+ caption: TextLabel?,
165
+ edges: { Frame },
166
+ innerEdges: { Frame },
167
+ dots: { Frame },
168
+ bars: { Bar },
169
+ connection: RBXScriptConnection?,
170
+
171
+ spin: Vector3,
172
+ angle: Vector3,
173
+ shapeIndex: number,
174
+
175
+ energy: number,
176
+ shake: number,
177
+ tint: Color3,
178
+ targetTint: Color3,
179
+ baseTint: Color3,
180
+ activeTint: Color3,
181
+ activity: number,
182
+ lastTitle: string?,
183
+ palette: { [string]: Color3 },
184
+ }
185
+
186
+ local runtime: Runtime = {
187
+ band = nil,
188
+ cell = nil,
189
+ trace = nil,
190
+ highlight = nil,
191
+ readout = nil,
192
+ caption = nil,
193
+ edges = {},
194
+ innerEdges = {},
195
+ dots = {},
196
+ bars = {},
197
+ connection = nil,
198
+ spin = Vector3.new(0.18, 0.27, 0.11),
199
+ angle = Vector3.zero,
200
+ shapeIndex = 1,
201
+ energy = 0,
202
+ shake = 0,
203
+ tint = Color3.fromRGB(167, 139, 250),
204
+ targetTint = Color3.fromRGB(167, 139, 250),
205
+ baseTint = Color3.fromRGB(167, 139, 250),
206
+ activeTint = Color3.fromRGB(167, 139, 250),
207
+ activity = 0,
208
+ lastTitle = nil,
209
+ palette = {},
210
+ }
211
+
212
+ local function rotate(point: Vector3, angle: Vector3): Vector3
213
+ local sinX, cosX = math.sin(angle.X), math.cos(angle.X)
214
+ local sinY, cosY = math.sin(angle.Y), math.cos(angle.Y)
215
+ local sinZ, cosZ = math.sin(angle.Z), math.cos(angle.Z)
216
+
217
+ local y1 = point.Y * cosX - point.Z * sinX
218
+ local z1 = point.Y * sinX + point.Z * cosX
219
+ local x2 = point.X * cosY + z1 * sinY
220
+ local z2 = -point.X * sinY + z1 * cosY
221
+ local x3 = x2 * cosZ - y1 * sinZ
222
+ local y3 = x2 * sinZ + y1 * cosZ
223
+
224
+ return Vector3.new(x3, y3, z2)
225
+ end
226
+
227
+ --[[
228
+ One edge as a rectangle turned about its midpoint. `Rotation` pivots a
229
+ GuiObject around its centre, which is exactly what a segment needs.
230
+ ]]
231
+ local function drawEdge(frame: Frame, a: Vector2, b: Vector2, thickness: number, color: Color3, transparency: number)
232
+ local delta = b - a
233
+ local length = delta.Magnitude
234
+ if length < 0.5 then
235
+ frame.Visible = false
236
+ return
237
+ end
238
+ frame.Visible = true
239
+ frame.Position = UDim2.fromOffset((a.X + b.X) / 2, (a.Y + b.Y) / 2)
240
+ frame.Size = UDim2.fromOffset(math.ceil(length), thickness)
241
+ frame.Rotation = math.deg(math.atan2(delta.Y, delta.X))
242
+ frame.BackgroundColor3 = color
243
+ frame.BackgroundTransparency = transparency
244
+ end
245
+
246
+ local function step(delta: number)
247
+ local band = runtime.band
248
+ if band == nil or not band.Visible then
249
+ return
250
+ end
251
+
252
+ runtime.energy = math.max(0, runtime.energy - delta * 0.9)
253
+ runtime.shake = math.max(0, runtime.shake - delta * 2.4)
254
+ runtime.activity = math.max(0, runtime.activity - delta * 0.35)
255
+ --[[
256
+ Two colours, blended by how recently something happened.
257
+
258
+ `baseTint` is the connection state, which is what the panel should read
259
+ as when nothing is going on. `activeTint` is the kind of work last done
260
+ -- reading, writing, running, debugging -- and it takes over while that
261
+ work is fresh, then fades back. So the strip is not one fixed colour: it
262
+ leans toward whatever the session is actually doing and returns to
263
+ resting on its own.
264
+ ]]
265
+ local blend = math.clamp(runtime.energy, 0, 1)
266
+ runtime.targetTint = runtime.baseTint:Lerp(runtime.activeTint, blend)
267
+ runtime.tint = runtime.tint:Lerp(runtime.targetTint, math.min(1, delta * 5))
268
+
269
+ -- Turning faster while busy makes the rate itself readable: a glance says
270
+ -- whether anything is happening without reading a word.
271
+ runtime.angle += runtime.spin * delta * (1 + runtime.energy * 2.2)
272
+
273
+ local shape = SHAPES[runtime.shapeIndex]
274
+ local centre = Vector2.new(CELL / 2, CELL / 2)
275
+ -- Kept clear of the cell's edge. The perspective divide pushes a near vertex
276
+ -- outward, so sizing to the full half-width clipped the solid against its own
277
+ -- container whenever a corner swung toward the viewer.
278
+ local radius = CELL * (0.2 + runtime.energy * 0.03)
279
+
280
+ -- Wobble the axis rather than the panel: shaking the strip would read as a
281
+ -- glitch, tilting the solid reads as it being knocked.
282
+ local wobble = if runtime.shake > 0
283
+ then Vector3.new(
284
+ math.sin(os.clock() * 37) * runtime.shake * 0.12,
285
+ math.cos(os.clock() * 29) * runtime.shake * 0.12,
286
+ 0
287
+ )
288
+ else Vector3.zero
289
+
290
+ local projected: { Vector2 } = {}
291
+ local depths: { number } = {}
292
+ for index, vertex in shape.vertices do
293
+ local turned = rotate(vertex, runtime.angle + wobble)
294
+ local scale = FOCAL / (FOCAL - turned.Z)
295
+ projected[index] = centre + Vector2.new(turned.X * scale * radius, turned.Y * scale * radius)
296
+ depths[index] = turned.Z
297
+ end
298
+
299
+ for index = 1, MAX_EDGES do
300
+ local frame = runtime.edges[index]
301
+ local edge = shape.edges[index]
302
+ if edge == nil then
303
+ frame.Visible = false
304
+ continue
305
+ end
306
+ local a, b = edge[1], edge[2]
307
+ local depth = math.clamp(((depths[a] + depths[b]) / 2 + 2) / 4, 0, 1)
308
+ drawEdge(
309
+ frame,
310
+ projected[a],
311
+ projected[b],
312
+ math.max(1, math.floor(1 + depth * 1.6)),
313
+ runtime.tint:Lerp(Color3.new(1, 1, 1), depth * 0.35),
314
+ 0.84 - depth * 0.74
315
+ )
316
+ end
317
+
318
+ --[[
319
+ Vertices as points, brighter than the edges they join.
320
+
321
+ Wireframes read as flat when every stroke is the same weight. Lighting
322
+ the corners gives the eye something to track as the solid turns, which is
323
+ what makes it read as rotating rather than merely flickering.
324
+ ]]
325
+ for index = 1, MAX_VERTICES do
326
+ local dot = runtime.dots[index]
327
+ local point = projected[index]
328
+ if point == nil then
329
+ dot.Visible = false
330
+ continue
331
+ end
332
+ local depth = math.clamp((depths[index] + 2) / 4, 0, 1)
333
+ local size = math.max(2, math.floor(1.5 + depth * 2.5))
334
+ dot.Visible = true
335
+ dot.Position = UDim2.fromOffset(point.X, point.Y)
336
+ dot.Size = UDim2.fromOffset(size, size)
337
+ dot.BackgroundColor3 = runtime.tint:Lerp(Color3.new(1, 1, 1), 0.25 + depth * 0.5)
338
+ dot.BackgroundTransparency = 0.55 - depth * 0.5
339
+ end
340
+
341
+ --[[
342
+ A second solid inside the first, turning the other way.
343
+
344
+ One rotating outline is a loading spinner. Two, at different rates and
345
+ opposite directions, read as a mechanism -- and the counter-rotation
346
+ makes the outer solid's direction obvious, which a lone wireframe hides
347
+ whenever it passes through a symmetrical angle.
348
+ ]]
349
+ local inner = SHAPES[INNER_SHAPE]
350
+ local innerAngle = -runtime.angle * 1.6
351
+ local innerProjected: { Vector2 } = {}
352
+ local innerDepths: { number } = {}
353
+ for index, vertex in inner.vertices do
354
+ local turned = rotate(vertex, innerAngle)
355
+ local scale = FOCAL / (FOCAL - turned.Z)
356
+ innerProjected[index] = centre
357
+ + Vector2.new(turned.X * scale * radius * INNER_SCALE, turned.Y * scale * radius * INNER_SCALE)
358
+ innerDepths[index] = turned.Z
359
+ end
360
+ for index, frame in runtime.innerEdges do
361
+ local edge = inner.edges[index]
362
+ if edge == nil then
363
+ frame.Visible = false
364
+ continue
365
+ end
366
+ local a, b = edge[1], edge[2]
367
+ local depth = math.clamp(((innerDepths[a] + innerDepths[b]) / 2 + 2) / 4, 0, 1)
368
+ drawEdge(
369
+ frame,
370
+ innerProjected[a],
371
+ innerProjected[b],
372
+ 1,
373
+ runtime.tint:Lerp(Color3.new(1, 1, 1), 0.5),
374
+ 0.9 - depth * 0.35
375
+ )
376
+ end
377
+ end
378
+
379
+ --[[
380
+ Puts the highlight behind one bar and prints its timing.
381
+
382
+ The trace is only legible if you already know what it plots, and nothing on
383
+ screen said so. Hovering answers it directly: the column lights up, and the
384
+ number that made that bar its height appears in words. One reading at a time,
385
+ so a single highlight and a single label are moved around rather than one of
386
+ each being built per bar.
387
+ ]]
388
+ local function showReading(entry: Bar)
389
+ local highlight = runtime.highlight
390
+ local readout = runtime.readout
391
+ if highlight == nil or readout == nil then
392
+ return
393
+ end
394
+
395
+ highlight.Position = UDim2.new(entry.hit.Position.X.Scale, entry.hit.Position.X.Offset, 0, 0)
396
+ highlight.Visible = true
397
+
398
+ readout.Text = if entry.ok
399
+ then string.format("%d ms", math.round(entry.milliseconds))
400
+ else string.format("failed after %d ms", math.round(entry.milliseconds))
401
+ readout.TextColor3 = if entry.ok
402
+ then runtime.palette.text or Color3.fromRGB(226, 232, 240)
403
+ else runtime.palette.red or Color3.fromRGB(251, 113, 133)
404
+ readout.Visible = true
405
+ end
406
+
407
+ local function clearReading()
408
+ if runtime.highlight then
409
+ runtime.highlight.Visible = false
410
+ end
411
+ if runtime.readout then
412
+ runtime.readout.Visible = false
413
+ end
414
+ end
415
+
416
+ --[[
417
+ Records one finished call as a bar on the trace.
418
+
419
+ Height is duration and colour is outcome, so a slow call and a failed one are
420
+ distinguishable at a glance -- which is the pair of questions that actually
421
+ get asked of a log this size.
422
+ ]]
423
+ function Visuals.recordCall(milliseconds: number, ok: boolean)
424
+ runtime.energy = math.min(1.6, runtime.energy + (if ok then 0.3 else 0.8))
425
+ runtime.activity = math.min(1, runtime.activity + 0.25)
426
+ runtime.shapeIndex = math.clamp(1 + math.floor(runtime.activity * (#SHAPES - 0.001)), 1, #SHAPES)
427
+ if not ok then
428
+ runtime.shake = 1
429
+ end
430
+
431
+ local trace = runtime.trace
432
+ if trace == nil then
433
+ return
434
+ end
435
+
436
+ local bar = Instance.new("Frame")
437
+ bar.AnchorPoint = Vector2.new(0, 1)
438
+ bar.BorderSizePixel = 0
439
+ bar.Parent = trace
440
+
441
+ -- A TextButton rather than a Frame: buttons take mouse events reliably in a
442
+ -- plugin widget, and with no text and no background it is purely a target.
443
+ local hit = Instance.new("TextButton")
444
+ hit.AnchorPoint = Vector2.new(0.5, 1)
445
+ hit.BackgroundTransparency = 1
446
+ hit.Text = ""
447
+ hit.AutoButtonColor = false
448
+ hit.BorderSizePixel = 0
449
+ hit.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
450
+ hit.Parent = trace
451
+
452
+ local entry: Bar = { frame = bar, hit = hit, milliseconds = milliseconds, ok = ok }
453
+ table.insert(runtime.bars, entry)
454
+
455
+ hit.MouseEnter:Connect(function()
456
+ showReading(entry)
457
+ end)
458
+ hit.MouseLeave:Connect(function()
459
+ clearReading()
460
+ end)
461
+
462
+ while #runtime.bars > TRACE do
463
+ local oldest = runtime.bars[1]
464
+ oldest.frame:Destroy()
465
+ oldest.hit:Destroy()
466
+ table.remove(runtime.bars, 1)
467
+ end
468
+
469
+ -- Laid out right to left so the newest bar is always at the same edge and
470
+ -- the trace reads as scrolling rather than reshuffling.
471
+ local total = #runtime.bars
472
+ for index, item in runtime.bars do
473
+ local fromRight = total - index
474
+ local height = math.clamp(item.milliseconds / SLOW_MS, 0.06, 1)
475
+ local offset = -(fromRight + 1) * BAR_STRIDE
476
+ item.frame.Position = UDim2.new(1, offset, 1, 0)
477
+ item.frame.Size = UDim2.new(0, 3, height, 0)
478
+ item.frame.BackgroundColor3 = if item.ok
479
+ then runtime.palette.violet or runtime.tint
480
+ else runtime.palette.red or Color3.fromRGB(251, 113, 133)
481
+ item.frame.BackgroundTransparency = 0.15 + (fromRight / TRACE) * 0.6
482
+ -- Centred on the bar, so the column a person points at is the one they get.
483
+ item.hit.Position = UDim2.new(1, offset + 1, 1, 0)
484
+ end
485
+
486
+ -- The bars have all moved, so a reading still on screen now names the wrong
487
+ -- one. Cheaper and more honest to drop it than to work out which bar the
488
+ -- pointer has ended up over.
489
+ clearReading()
490
+ end
491
+
492
+ --[[
493
+ The resting colour, which is the connection state.
494
+ ]]
495
+ function Visuals.setTint(color: Color3)
496
+ runtime.baseTint = color
497
+ end
498
+
499
+ --[[
500
+ The colour and pace of the work now running.
501
+
502
+ Both are set from the same call because they describe the same thing: what
503
+ kind of command this is. Reads are quick and cool, writes are slower and
504
+ warm, so the strip's colour and its rate agree with each other instead of
505
+ moving independently.
506
+ ]]
507
+ function Visuals.setKind(color: Color3, urgency: number)
508
+ runtime.activeTint = color
509
+ runtime.energy = math.min(1.6, math.max(runtime.energy, urgency))
510
+ -- Re-aimed per kind so successive commands of different types visibly
511
+ -- change the axis rather than continuing the same turn.
512
+ runtime.spin = Vector3.new(
513
+ 0.12 + math.random() * 0.16,
514
+ 0.18 + math.random() * 0.22,
515
+ 0.06 + math.random() * 0.12
516
+ )
517
+ end
518
+
519
+ --[[
520
+ What is running, in the same words the log uses. The motion says something is
521
+ happening; this says what, and neither answers the other's question.
522
+ ]]
523
+ function Visuals.setCaption(title: string)
524
+ runtime.lastTitle = title
525
+ if runtime.caption then
526
+ runtime.caption.Text = title
527
+ end
528
+ end
529
+
530
+ --[[
531
+ Falls back to the last thing that ran once a command finishes.
532
+
533
+ The footer already reports totals, so repeating "idle" here would say the
534
+ same word twice on one screen. What has just happened is more useful and is
535
+ not shown anywhere else.
536
+ ]]
537
+ function Visuals.setIdle()
538
+ if runtime.caption == nil then
539
+ return
540
+ end
541
+ runtime.caption.Text = if runtime.lastTitle ~= nil
542
+ then "last: " .. runtime.lastTitle
543
+ else "waiting for a command"
544
+ end
545
+
546
+ function Visuals.setVisible(visible: boolean)
547
+ local band = runtime.band
548
+ if band == nil then
549
+ return
550
+ end
551
+ band.Visible = visible
552
+
553
+ -- Connected only while on screen: a hidden strip that keeps projecting
554
+ -- geometry every frame is a battery complaint waiting to happen.
555
+ if visible and runtime.connection == nil then
556
+ runtime.connection = RunService.Heartbeat:Connect(step)
557
+ elseif not visible and runtime.connection ~= nil then
558
+ runtime.connection:Disconnect()
559
+ runtime.connection = nil
560
+ end
561
+ end
562
+
563
+ function Visuals.isVisible(): boolean
564
+ local band = runtime.band
565
+ return band ~= nil and band.Visible
566
+ end
567
+
568
+ --[[
569
+ Builds the strip. Every edge frame is created here and never again, which is
570
+ what keeps the per-frame path allocation-free.
571
+ ]]
572
+ function Visuals.mount(parent: Instance, palette: { [string]: Color3 })
573
+ runtime.palette = palette
574
+
575
+ local band = Instance.new("Frame")
576
+ band.Name = "ActivityBand"
577
+ band.BackgroundColor3 = palette.surface
578
+ band.BorderSizePixel = 0
579
+ band.Visible = false
580
+ band.ClipsDescendants = true
581
+ band.Parent = parent
582
+ runtime.band = band
583
+
584
+ local cell = Instance.new("Frame")
585
+ cell.Name = "Solid"
586
+ cell.Position = UDim2.fromOffset(6, (Visuals.BAND_HEIGHT - CELL) / 2)
587
+ cell.Size = UDim2.fromOffset(CELL, CELL)
588
+ cell.BackgroundTransparency = 1
589
+ cell.ClipsDescendants = true
590
+ cell.Parent = band
591
+ runtime.cell = cell
592
+
593
+ for index = 1, MAX_EDGES do
594
+ local edge = Instance.new("Frame")
595
+ edge.Name = string.format("Edge%02d", index)
596
+ edge.AnchorPoint = Vector2.new(0.5, 0.5)
597
+ edge.BorderSizePixel = 0
598
+ edge.Visible = false
599
+ edge.Parent = cell
600
+ runtime.edges[index] = edge
601
+ end
602
+
603
+ -- A hairline between the solid and the trace, so the strip reads as two
604
+ -- instruments rather than one busy rectangle.
605
+ local divider = Instance.new("Frame")
606
+ divider.Position = UDim2.fromOffset(CELL + GUTTER / 2, 8)
607
+ divider.Size = UDim2.new(0, 1, 1, -16)
608
+ divider.BackgroundColor3 = palette.dim
609
+ divider.BackgroundTransparency = 0.7
610
+ divider.BorderSizePixel = 0
611
+ divider.Parent = band
612
+
613
+ for index = 1, MAX_VERTICES do
614
+ local dot = Instance.new("Frame")
615
+ dot.Name = string.format("Vertex%02d", index)
616
+ dot.AnchorPoint = Vector2.new(0.5, 0.5)
617
+ dot.BorderSizePixel = 0
618
+ dot.Visible = false
619
+ dot.Parent = cell
620
+ local round = Instance.new("UICorner")
621
+ round.CornerRadius = UDim.new(1, 0)
622
+ round.Parent = dot
623
+ runtime.dots[index] = dot
624
+ end
625
+
626
+ for index = 1, #SHAPES[INNER_SHAPE].edges do
627
+ local edge = Instance.new("Frame")
628
+ edge.Name = string.format("Inner%02d", index)
629
+ edge.AnchorPoint = Vector2.new(0.5, 0.5)
630
+ edge.BorderSizePixel = 0
631
+ edge.Visible = false
632
+ edge.Parent = cell
633
+ runtime.innerEdges[index] = edge
634
+ end
635
+
636
+ local caption = Instance.new("TextLabel")
637
+ caption.Position = UDim2.new(0, CELL + GUTTER, 0, 4)
638
+ caption.Size = UDim2.new(1, -CELL - GUTTER - 12, 0, 14)
639
+ caption.BackgroundTransparency = 1
640
+ caption.Font = Enum.Font.Code
641
+ caption.TextSize = 11
642
+ caption.TextColor3 = palette.text
643
+ caption.TextXAlignment = Enum.TextXAlignment.Left
644
+ caption.TextTruncate = Enum.TextTruncate.AtEnd
645
+ caption.Text = "idle"
646
+ caption.Parent = band
647
+ runtime.caption = caption
648
+
649
+ local trace = Instance.new("Frame")
650
+ trace.Name = "Trace"
651
+ trace.Position = UDim2.new(0, CELL + GUTTER, 0, 21)
652
+ trace.Size = UDim2.new(1, -CELL - GUTTER - 12, 0, 19)
653
+ trace.BackgroundTransparency = 1
654
+ trace.ClipsDescendants = true
655
+ trace.Parent = band
656
+ runtime.trace = trace
657
+
658
+ -- A baseline under the bars, so an empty trace still reads as an instrument
659
+ -- waiting for data rather than as a blank gap.
660
+ local baseline = Instance.new("Frame")
661
+ baseline.AnchorPoint = Vector2.new(0, 1)
662
+ baseline.Position = UDim2.fromScale(0, 1)
663
+ baseline.Size = UDim2.new(1, 0, 0, 1)
664
+ baseline.BackgroundColor3 = palette.dim
665
+ baseline.BackgroundTransparency = 0.75
666
+ baseline.BorderSizePixel = 0
667
+ baseline.Parent = trace
668
+
669
+ --[[
670
+ Built once and moved, and created BEFORE the bars exist so it sits under
671
+ them in draw order -- a highlight drawn over a one-pixel bar would hide the
672
+ very thing it is pointing at.
673
+ ]]
674
+ local highlight = Instance.new("Frame")
675
+ highlight.Name = "Highlight"
676
+ highlight.AnchorPoint = Vector2.new(0.5, 1)
677
+ highlight.Position = UDim2.new(1, 0, 1, 0)
678
+ highlight.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
679
+ highlight.BackgroundColor3 = palette.text or Color3.fromRGB(226, 232, 240)
680
+ highlight.BackgroundTransparency = 0.85
681
+ highlight.BorderSizePixel = 0
682
+ highlight.Visible = false
683
+ highlight.Parent = trace
684
+ runtime.highlight = highlight
685
+
686
+ --[[
687
+ The reading sits at the left end of the trace rather than beside the bar it
688
+ describes. Following the pointer would put it off the right edge for the
689
+ newest bars, which are the ones most often asked about, and the left end is
690
+ empty until forty calls have accumulated.
691
+ ]]
692
+ local readout = Instance.new("TextLabel")
693
+ readout.Name = "Readout"
694
+ readout.AnchorPoint = Vector2.new(0, 0.5)
695
+ readout.Position = UDim2.new(0, 0, 0.5, 0)
696
+ readout.Size = UDim2.new(0, 110, 0, 14)
697
+ readout.BackgroundColor3 = palette.background or Color3.fromRGB(11, 12, 20)
698
+ readout.BackgroundTransparency = 0.15
699
+ readout.BorderSizePixel = 0
700
+ readout.Font = Enum.Font.Code
701
+ readout.TextSize = 11
702
+ readout.TextXAlignment = Enum.TextXAlignment.Left
703
+ readout.Text = ""
704
+ readout.Visible = false
705
+ readout.ZIndex = 3
706
+ readout.Parent = trace
707
+ runtime.readout = readout
708
+ end
709
+
710
+ return Visuals