@el4cteo/rbx-studio-mcp 0.6.0 → 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 (48) hide show
  1. package/README.md +4 -4
  2. package/dist/index.js +4 -0
  3. package/dist/index.js.map +1 -1
  4. package/dist/tools/anim.js +159 -0
  5. package/dist/tools/anim.js.map +1 -0
  6. package/dist/tools/character.js +95 -5
  7. package/dist/tools/character.js.map +1 -1
  8. package/dist/tools/data.js +173 -0
  9. package/dist/tools/data.js.map +1 -0
  10. package/dist/tools/device.js +77 -7
  11. package/dist/tools/device.js.map +1 -1
  12. package/dist/tools/discover.js +80 -4
  13. package/dist/tools/discover.js.map +1 -1
  14. package/dist/tools/exec.js +48 -1
  15. package/dist/tools/exec.js.map +1 -1
  16. package/dist/tools/input.js +35 -9
  17. package/dist/tools/input.js.map +1 -1
  18. package/dist/tools/perf.js +74 -7
  19. package/dist/tools/perf.js.map +1 -1
  20. package/dist/tools/scripts.js +51 -3
  21. package/dist/tools/scripts.js.map +1 -1
  22. package/dist/tools/world.js +317 -44
  23. package/dist/tools/world.js.map +1 -1
  24. package/package.json +74 -74
  25. package/plugin/src/Commands.luau +622 -562
  26. package/plugin/src/Config.luau +65 -65
  27. package/plugin/src/Console.luau +1909 -1843
  28. package/plugin/src/Emulation.luau +172 -0
  29. package/plugin/src/Phrase.luau +164 -16
  30. package/plugin/src/Png.luau +8 -4
  31. package/plugin/src/Serialize.luau +499 -327
  32. package/plugin/src/Undo.luau +94 -6
  33. package/plugin/src/handlers/Anim.luau +897 -0
  34. package/plugin/src/handlers/Assets.luau +587 -352
  35. package/plugin/src/handlers/Capture.luau +155 -20
  36. package/plugin/src/handlers/Character.luau +823 -361
  37. package/plugin/src/handlers/Data.luau +539 -0
  38. package/plugin/src/handlers/Device.luau +394 -139
  39. package/plugin/src/handlers/Discover.luau +685 -363
  40. package/plugin/src/handlers/Geometry.luau +127 -0
  41. package/plugin/src/handlers/Perf.luau +227 -0
  42. package/plugin/src/handlers/Scripts.luau +673 -539
  43. package/plugin/src/handlers/Session.luau +3 -0
  44. package/plugin/src/handlers/Viewport.luau +268 -0
  45. package/plugin/src/handlers/World.luau +89 -15
  46. package/plugin/src/init.server.luau +879 -832
  47. package/scripts/build-plugin.mjs +20 -0
  48. package/scripts/check-plugin.mjs +171 -124
@@ -146,6 +146,9 @@ function Session.status(): { [string]: any }
146
146
  scriptCount = totals.scripts,
147
147
  studioVersion = studioVersion(),
148
148
  emulatedDevice = Emulation.summary(),
149
+ -- Separate from the device: traffic can be shaped with no device set,
150
+ -- and that is the case most likely to be forgotten about.
151
+ emulatedNetwork = Emulation.networkSummary(),
149
152
  }
150
153
  end
151
154
 
@@ -15,10 +15,12 @@
15
15
 
16
16
  local RunService = game:GetService("RunService")
17
17
  local Selection = game:GetService("Selection")
18
+ local StarterGui = game:GetService("StarterGui")
18
19
  local TextService = game:GetService("TextService")
19
20
  local Workspace = game:GetService("Workspace")
20
21
 
21
22
  local Dispatch = require(script.Parent.Parent.Dispatch)
23
+ local Emulation = require(script.Parent.Parent.Emulation)
22
24
  local Paths = require(script.Parent.Parent.Paths)
23
25
  local Serialize = require(script.Parent.Parent.Serialize)
24
26
 
@@ -403,8 +405,274 @@ function Viewport.textbounds(params: { [string]: any }): { [string]: any }
403
405
  return report
404
406
  end
405
407
 
408
+ --[[
409
+ Every interface fault that is invisible in the data model.
410
+
411
+ `textbounds` already answers "does this text fit its label". This is the same
412
+ question asked of the whole screen, and it exists because the data model
413
+ cannot answer it at all: a button positioned off the side of a phone has a
414
+ perfectly correct `Position`, a `Size` that is not zero, and a parent that is
415
+ visible. Nothing about the instance is wrong. It is simply not where anyone
416
+ can reach it, and the only ways to find that out today are to play the game
417
+ or to look at a picture.
418
+
419
+ Studio lays StarterGui out for real in edit mode -- measured:
420
+ `AbsolutePosition` and `AbsoluteSize` carry live values with nothing running,
421
+ and they move when a device is emulated. So the audit runs against whatever
422
+ `device` is currently emulating, which is what makes it worth running twice:
423
+ once on desktop, once on a phone.
424
+
425
+ It reports rather than judges. Every finding names the element and the
426
+ number, so a caller can decide whether a label four pixels off the edge is a
427
+ bug or a deliberate bleed.
428
+ ]]
429
+
430
+ --[[
431
+ Text below this many pixels tall is not readable on a phone.
432
+
433
+ Roblox's own UI guidance and every accessibility standard land in the same
434
+ place: somewhere around 12 device-independent pixels is the floor for body
435
+ text. 10 is used here because it is the point past which there is no
436
+ argument -- a label under it is unreadable on any display, not merely small.
437
+ ]]
438
+ local MIN_TEXT_PIXELS = 10
439
+
440
+ --[[
441
+ Pairs compared for overlap before the check gives up.
442
+
443
+ Overlap is quadratic in the number of siblings, and a scrolling list of two
444
+ hundred rows is both a legitimate design and forty thousand comparisons of
445
+ rectangles that are supposed to sit next to each other. Past this the check
446
+ reports that it stopped rather than continuing to spend the user's frame on
447
+ an answer nobody asked for.
448
+ ]]
449
+ local MAX_OVERLAP_PAIRS = 2000
450
+
451
+ local function rectOf(element: GuiObject): (number, number, number, number)
452
+ local position = element.AbsolutePosition
453
+ local size = element.AbsoluteSize
454
+ return position.X, position.Y, position.X + size.X, position.Y + size.Y
455
+ end
456
+
457
+ local function overlaps(a: GuiObject, b: GuiObject): boolean
458
+ local ax1, ay1, ax2, ay2 = rectOf(a)
459
+ local bx1, by1, bx2, by2 = rectOf(b)
460
+ return ax1 < bx2 and bx1 < ax2 and ay1 < by2 and by1 < ay2
461
+ end
462
+
463
+ --[[
464
+ Whether this element is actually on screen at all.
465
+
466
+ Walks up rather than trusting `Visible`: an element with Visible true inside
467
+ a frame with Visible false is not on screen, and reporting it as a fault
468
+ would bury the real findings under every hidden menu in the place.
469
+ ]]
470
+ local function shown(element: Instance): boolean
471
+ local node: Instance? = element
472
+ while node ~= nil and not node:IsA("LayerCollector") do
473
+ if node:IsA("GuiObject") and not (node :: GuiObject).Visible then
474
+ return false
475
+ end
476
+ node = node.Parent
477
+ end
478
+ if node ~= nil and node:IsA("ScreenGui") then
479
+ return (node :: ScreenGui).Enabled
480
+ end
481
+ return node ~= nil
482
+ end
483
+
484
+ function Viewport.ui(params: { [string]: any }): { [string]: any }
485
+ local root: Instance = if typeof(params.path) == "string" and params.path ~= ""
486
+ then Paths.resolve(params.path)
487
+ else StarterGui
488
+
489
+ local camera = Workspace.CurrentCamera
490
+ if camera == nil then
491
+ Dispatch.fail("NO_CAMERA", "This session has no camera, so there is no screen to measure against.")
492
+ end
493
+ local screen = (camera :: Camera).ViewportSize
494
+ local screenWidth, screenHeight = math.floor(screen.X), math.floor(screen.Y)
495
+
496
+ local elements: { GuiObject } = {}
497
+ for _, descendant in root:GetDescendants() do
498
+ if descendant:IsA("GuiObject") then
499
+ table.insert(elements, descendant :: GuiObject)
500
+ end
501
+ end
502
+
503
+ local findings: { { [string]: any } } = {}
504
+ local checked = 0
505
+ local hidden = 0
506
+
507
+ local function report(element: GuiObject, issue: string, detail: string)
508
+ if #findings >= 100 then
509
+ return
510
+ end
511
+ table.insert(findings, {
512
+ path = Paths.of(element),
513
+ name = element.Name,
514
+ className = element.ClassName,
515
+ issue = issue,
516
+ detail = detail,
517
+ })
518
+ end
519
+
520
+ local visible: { GuiObject } = {}
521
+
522
+ for _, element in elements do
523
+ if not shown(element) then
524
+ hidden += 1
525
+ continue
526
+ end
527
+ checked += 1
528
+ table.insert(visible, element)
529
+
530
+ local size = element.AbsoluteSize
531
+ local position = element.AbsolutePosition
532
+
533
+ if size.X <= 0 or size.Y <= 0 then
534
+ report(element, "zero size", string.format("%.0f x %.0f -- nothing is drawn", size.X, size.Y))
535
+ continue
536
+ end
537
+
538
+ --[[
539
+ Off screen, and by how much.
540
+
541
+ Reported as a measurement rather than a yes/no, because the two cases
542
+ it covers are different bugs: an element wholly outside the screen was
543
+ positioned for a display that is not this one, and one clipped at an
544
+ edge is usually a layout that does not fit. The numbers tell them
545
+ apart; a boolean would not.
546
+ ]]
547
+ local right = position.X + size.X
548
+ local bottom = position.Y + size.Y
549
+ if right <= 0 or bottom <= 0 or position.X >= screenWidth or position.Y >= screenHeight then
550
+ report(
551
+ element,
552
+ "off screen",
553
+ string.format(
554
+ "at %.0f,%.0f on a %dx%d screen -- entirely outside it",
555
+ position.X,
556
+ position.Y,
557
+ screenWidth,
558
+ screenHeight
559
+ )
560
+ )
561
+ elseif position.X < 0 or position.Y < 0 or right > screenWidth or bottom > screenHeight then
562
+ report(
563
+ element,
564
+ "clipped",
565
+ string.format(
566
+ "%.0f,%.0f to %.0f,%.0f runs past the %dx%d screen",
567
+ position.X,
568
+ position.Y,
569
+ right,
570
+ bottom,
571
+ screenWidth,
572
+ screenHeight
573
+ )
574
+ )
575
+ end
576
+
577
+ if element:IsA("TextLabel") or element:IsA("TextButton") or element:IsA("TextBox") then
578
+ local text = element :: any
579
+ --[[
580
+ The size the text is actually drawn at, which is not TextSize when
581
+ TextScaled is on: the engine then fits the string to the box and
582
+ `TextBounds` is the only honest report of the result.
583
+ ]]
584
+ local drawn = if text.TextScaled then text.TextBounds.Y else text.TextSize
585
+ if drawn > 0 and drawn < MIN_TEXT_PIXELS and text.Text ~= "" then
586
+ report(
587
+ element,
588
+ "text too small",
589
+ string.format("%.0fpx -- under %dpx is unreadable on a phone", drawn, MIN_TEXT_PIXELS)
590
+ )
591
+ end
592
+ if text.TextTruncate == Enum.TextTruncate.None and not text.TextScaled and not text.TextWrapped then
593
+ if text.TextBounds.X > size.X + 1 then
594
+ report(
595
+ element,
596
+ "text overflows",
597
+ string.format("needs %.0fpx, has %.0fpx", text.TextBounds.X, size.X)
598
+ )
599
+ end
600
+ end
601
+ end
602
+ end
603
+
604
+ --[[
605
+ Overlap between siblings only.
606
+
607
+ A child sitting on top of its parent is the normal way UI is built, and
608
+ reporting it would make the check useless. Two buttons in the same frame
609
+ covering each other is the fault worth naming.
610
+ ]]
611
+ --[[
612
+ Whether an element is something a user reads or presses.
613
+
614
+ Overlap between decoration is not a fault, it is how UI is built: a drop
615
+ shadow sits under a panel, a bevel sits under a label, a playhead sits
616
+ over a timeline. Measured on a real HUD, the first version of this check
617
+ reported four overlaps and all four were of that kind -- a Frame named
618
+ Shadow behind the three things it was drawn to sit behind.
619
+
620
+ Two pieces of TEXT on top of each other, or two images, is the fault
621
+ worth naming. So a plain coloured Frame is not counted; something
622
+ carrying text or an image is.
623
+ ]]
624
+ local function carriesContent(element: GuiObject): boolean
625
+ if element:IsA("TextLabel") or element:IsA("TextButton") or element:IsA("TextBox") then
626
+ return (element :: any).Text ~= ""
627
+ end
628
+ if element:IsA("ImageLabel") or element:IsA("ImageButton") then
629
+ return (element :: any).Image ~= ""
630
+ end
631
+ return false
632
+ end
633
+
634
+ local pairs = 0
635
+ local stopped = false
636
+ for index = 1, #visible do
637
+ for other = index + 1, #visible do
638
+ if pairs >= MAX_OVERLAP_PAIRS then
639
+ stopped = true
640
+ break
641
+ end
642
+ local a, b = visible[index], visible[other]
643
+ if a.Parent == b.Parent then
644
+ pairs += 1
645
+ if
646
+ overlaps(a, b)
647
+ and a.AbsoluteSize.X > 0
648
+ and b.AbsoluteSize.X > 0
649
+ and carriesContent(a)
650
+ and carriesContent(b)
651
+ then
652
+ report(a, "overlaps", string.format("covers or is covered by %s", b.Name))
653
+ end
654
+ end
655
+ end
656
+ if stopped then
657
+ break
658
+ end
659
+ end
660
+
661
+ return {
662
+ screen = string.format("%dx%d", screenWidth, screenHeight),
663
+ device = Emulation.deviceId(),
664
+ root = Paths.of(root),
665
+ checked = checked,
666
+ hidden = hidden,
667
+ findings = findings,
668
+ findingCount = #findings,
669
+ overlapStopped = stopped,
670
+ }
671
+ end
672
+
406
673
  function Viewport.register()
407
674
  Dispatch.registerAll("viewport", {
675
+ ui = Viewport.ui,
408
676
  textbounds = Viewport.textbounds,
409
677
  raycast = Viewport.raycast,
410
678
  select = Viewport.select,
@@ -18,15 +18,21 @@
18
18
  local ChangeHistoryService = game:GetService("ChangeHistoryService")
19
19
 
20
20
  --[[
21
- Collision groups are read and written through the Workspace, not PhysicsService.
21
+ Collision groups are read and written through a WorldRoot, not PhysicsService.
22
22
 
23
23
  Roblox moved collision group management onto WorldRoot in September 2026 and
24
24
  deprecated the PhysicsService methods in the same announcement. Both still
25
- work today -- checked against Studio 0.737 -- so this is a migration ahead of
26
- a removal rather than a fix. The move also means a WorldModel gets its own
27
- groups instead of sharing the Workspace's, which is the point of it.
25
+ work today -- checked against Studio 0.738 -- so this is a migration ahead of
26
+ a removal rather than a fix.
27
+
28
+ The move is not only a rename. A `WorldModel` is a WorldRoot too, and it now
29
+ keeps its OWN registry: a group named "Doors" inside a viewport's WorldModel
30
+ is a different group from the Workspace's "Doors", with its own collidability
31
+ matrix. Going through `PhysicsService` -- or hard-coding `workspace` here --
32
+ can only ever reach one of them, which would quietly make this tool lie about
33
+ a place that uses both. So the root is chosen per call; see `root`.
28
34
  ]]
29
- local CollisionGroups = workspace
35
+ local Workspace = game:GetService("Workspace")
30
36
 
31
37
  local Dispatch = require(script.Parent.Parent.Dispatch)
32
38
  local Paths = require(script.Parent.Parent.Paths)
@@ -34,6 +40,33 @@ local Undo = require(script.Parent.Parent.Undo)
34
40
 
35
41
  local World = {}
36
42
 
43
+ --[[
44
+ Which world the call is about.
45
+
46
+ Defaults to the Workspace, which is what nearly every caller means. Naming a
47
+ `worldModel` path targets that model's private registry instead -- the
48
+ viewport-preview case -- and anything that is not a WorldRoot is refused by
49
+ name rather than left to fail later inside `RegisterCollisionGroup` with a
50
+ message about a method that does not exist.
51
+ ]]
52
+ local function root(params: { [string]: any }): Instance
53
+ local path = params.worldModel
54
+ if typeof(path) ~= "string" or path == "" then
55
+ return Workspace
56
+ end
57
+
58
+ local instance = Paths.resolve(path)
59
+ if not instance:IsA("WorldRoot") then
60
+ Dispatch.fail(
61
+ "BAD_PARAMS",
62
+ string.format("%s is a %s, not a WorldModel.", path, instance.ClassName),
63
+ "Collision groups live on a WorldRoot: the Workspace, or a WorldModel "
64
+ .. "inside a ViewportFrame. Omit `worldModel` for the Workspace."
65
+ )
66
+ end
67
+ return instance
68
+ end
69
+
37
70
  --[[
38
71
  Steps the undo stack.
39
72
 
@@ -101,18 +134,59 @@ end
101
134
  ]]
102
135
  function World.collision(params: { [string]: any }): { [string]: any }
103
136
  local action = tostring(params.action or "list")
137
+ local world = root(params) :: any
104
138
 
105
139
  if action == "list" then
106
140
  local groups: { { [string]: any } } = {}
107
141
  local ok, registered = pcall(function()
108
- return CollisionGroups:GetRegisteredCollisionGroups()
142
+ return world:GetRegisteredCollisionGroups()
109
143
  end)
110
144
  if ok and typeof(registered) == "table" then
145
+ --[[
146
+ Named pairs, not the raw mask.
147
+
148
+ `mask` is the engine's bitfield, and it is not readable: a group
149
+ that passes through only itself reports -3, which says nothing to
150
+ anyone without a calculator and the group ordering to hand. The
151
+ whole reason a collision group exists is "these two do not
152
+ collide", so that is what is listed -- by name, both ways round,
153
+ which is the answer the caller came for.
154
+
155
+ The mask is kept beside it, because a caller comparing against
156
+ something they stored earlier still needs the number.
157
+ ]]
111
158
  for _, group in registered :: { any } do
112
- table.insert(groups, { name = group.name, mask = group.mask })
159
+ local passesThrough: { string } = {}
160
+ for _, other in registered :: { any } do
161
+ local okPair, collides = pcall(function()
162
+ return world:CollisionGroupsAreCollidable(group.name, other.name)
163
+ end)
164
+ if okPair and collides == false then
165
+ table.insert(passesThrough, other.name)
166
+ end
167
+ end
168
+ table.sort(passesThrough)
169
+ table.insert(groups, {
170
+ name = group.name,
171
+ mask = group.mask,
172
+ passesThrough = if #passesThrough > 0 then table.concat(passesThrough, ", ") else "nothing",
173
+ })
113
174
  end
114
175
  end
115
- return { groups = groups }
176
+ --[[
177
+ The ceiling is reported alongside the groups because it is low --
178
+ 32 in practice -- and a caller building groups programmatically has
179
+ no other way to learn it before the registration that fails.
180
+ ]]
181
+ local limit: number? = nil
182
+ local okLimit, maximum = pcall(function()
183
+ return world:GetMaxCollisionGroups()
184
+ end)
185
+ if okLimit then
186
+ limit = tonumber(maximum)
187
+ end
188
+
189
+ return { groups = groups, world = Paths.of(world), count = #groups, max = limit }
116
190
  end
117
191
 
118
192
  local name = params.group
@@ -122,7 +196,7 @@ function World.collision(params: { [string]: any }): { [string]: any }
122
196
 
123
197
  if action == "create" then
124
198
  local ok, err = pcall(function()
125
- CollisionGroups:RegisterCollisionGroup(name)
199
+ world:RegisterCollisionGroup(name)
126
200
  end)
127
201
  if not ok then
128
202
  -- Already existing is the common case and is not a failure worth
@@ -131,7 +205,7 @@ function World.collision(params: { [string]: any }): { [string]: any }
131
205
  Dispatch.fail("COLLISION_FAILED", string.format("Could not create %q: %s", name, tostring(err)))
132
206
  end
133
207
  end
134
- return { group = name, created = ok, existed = not ok }
208
+ return { group = name, created = ok, existed = not ok, world = Paths.of(world) }
135
209
  end
136
210
 
137
211
  if action == "assign" then
@@ -154,7 +228,7 @@ function World.collision(params: { [string]: any }): { [string]: any }
154
228
  end
155
229
  end
156
230
  end)
157
- return { group = name, assigned = #assigned, parts = assigned, undoable = undoable }
231
+ return { group = name, assigned = #assigned, parts = assigned, undoable = undoable, world = Paths.of(world) }
158
232
  end
159
233
 
160
234
  if action == "remove" then
@@ -168,12 +242,12 @@ function World.collision(params: { [string]: any }): { [string]: any }
168
242
  has existed the whole time; it was just never wired up.
169
243
  ]]
170
244
  local ok, err = pcall(function()
171
- CollisionGroups:UnregisterCollisionGroup(name)
245
+ world:UnregisterCollisionGroup(name)
172
246
  end)
173
247
  if not ok then
174
248
  Dispatch.fail("COLLISION_FAILED", string.format("Could not remove %q: %s", name, tostring(err)))
175
249
  end
176
- return { group = name, removed = true }
250
+ return { group = name, removed = true, world = Paths.of(world) }
177
251
  end
178
252
 
179
253
  if action == "collidable" then
@@ -183,12 +257,12 @@ function World.collision(params: { [string]: any }): { [string]: any }
183
257
  end
184
258
  local collidable = params.collidable ~= false
185
259
  local ok, err = pcall(function()
186
- CollisionGroups:CollisionGroupSetCollidable(name, other, collidable)
260
+ world:CollisionGroupSetCollidable(name, other, collidable)
187
261
  end)
188
262
  if not ok then
189
263
  Dispatch.fail("COLLISION_FAILED", string.format("Could not set collidability: %s", tostring(err)))
190
264
  end
191
- return { group = name, with = other, collidable = collidable }
265
+ return { group = name, with = other, collidable = collidable, world = Paths.of(world) }
192
266
  end
193
267
 
194
268
  Dispatch.fail("BAD_PARAMS", string.format("unknown collision action %q", action))