@el4cteo/rbx-studio-mcp 0.7.7 → 0.8.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 (59) hide show
  1. package/README.md +175 -175
  2. package/dist/bridge/harness.js +14 -5
  3. package/dist/bridge/harness.js.map +1 -1
  4. package/dist/index.js +5 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/apidump.js +38 -23
  7. package/dist/lib/apidump.js.map +1 -1
  8. package/dist/lib/format.js +43 -4
  9. package/dist/lib/format.js.map +1 -1
  10. package/dist/lib/pluginbuild.js +10 -0
  11. package/dist/lib/pluginbuild.js.map +1 -1
  12. package/dist/lib/png.js +104 -0
  13. package/dist/lib/png.js.map +1 -1
  14. package/dist/resources.js +3 -2
  15. package/dist/resources.js.map +1 -1
  16. package/dist/tools/api.js +4 -3
  17. package/dist/tools/api.js.map +1 -1
  18. package/dist/tools/debug.js +2 -2
  19. package/dist/tools/debug.js.map +1 -1
  20. package/dist/tools/device.js +2 -2
  21. package/dist/tools/device.js.map +1 -1
  22. package/dist/tools/discover.js +7 -3
  23. package/dist/tools/discover.js.map +1 -1
  24. package/dist/tools/exec.js +4 -4
  25. package/dist/tools/exec.js.map +1 -1
  26. package/dist/tools/instances.js +32 -7
  27. package/dist/tools/instances.js.map +1 -1
  28. package/dist/tools/perf.js +35 -13
  29. package/dist/tools/perf.js.map +1 -1
  30. package/dist/tools/playtest.js +34 -13
  31. package/dist/tools/playtest.js.map +1 -1
  32. package/dist/tools/screenshot.js +55 -11
  33. package/dist/tools/screenshot.js.map +1 -1
  34. package/dist/tools/scripts.js +4 -4
  35. package/dist/tools/scripts.js.map +1 -1
  36. package/dist/tools/terrain.js +3 -3
  37. package/dist/tools/terrain.js.map +1 -1
  38. package/dist/tools/world.js +8 -8
  39. package/dist/tools/world.js.map +1 -1
  40. package/package.json +75 -75
  41. package/plugin/src/ClientRelay.luau +128 -1
  42. package/plugin/src/Config.luau +1 -1
  43. package/plugin/src/LogBuffer.luau +363 -277
  44. package/plugin/src/Paths.luau +15 -4
  45. package/plugin/src/Phrase.luau +1 -20
  46. package/plugin/src/ScriptEdit.luau +14 -0
  47. package/plugin/src/Serialize.luau +17 -0
  48. package/plugin/src/TextEdit.luau +6 -0
  49. package/plugin/src/handlers/Api.luau +8 -0
  50. package/plugin/src/handlers/Capture.luau +219 -3
  51. package/plugin/src/handlers/Perf.luau +913 -889
  52. package/plugin/src/handlers/Playtest.luau +6 -1
  53. package/plugin/src/init.server.luau +3 -2
  54. package/scripts/test-live.mjs +106 -1
  55. package/scripts/test-plugin.mjs +2 -0
  56. package/scripts/test-tools.mjs +259 -162
  57. package/dist/tools/anim.js +0 -159
  58. package/dist/tools/anim.js.map +0 -1
  59. package/plugin/src/handlers/Anim.luau +0 -897
@@ -147,13 +147,19 @@ local function childBySegment(
147
147
 
148
148
  if ordinal then
149
149
  local list = childrenByName(parent, nil)[name]
150
- if list == nil then
151
- return nil, nil
152
- end
153
- local child = list[ordinal]
150
+ local child = if list then list[ordinal] else nil
154
151
  if child then
155
152
  return child, nil
156
153
  end
154
+ -- Not an index after all: a child literally named "x[1]". Typed by hand,
155
+ -- or from a path emitted before `Paths.of` began marking such names.
156
+ local literal = parent:FindFirstChild(segment)
157
+ if literal then
158
+ return literal, nil
159
+ end
160
+ if list == nil then
161
+ return nil, nil
162
+ end
157
163
  return nil,
158
164
  string.format(
159
165
  '"%s" has %d child%s named "%s", so [%d] is past the end. Indexes are '
@@ -343,6 +349,11 @@ function Paths.of(instance: Instance, memo: NameIndex?): string
343
349
  while current ~= nil and current ~= game do
344
350
  local node = current :: Instance
345
351
  local ordinal = siblingIndex(node, memo)
352
+ -- A name that itself ends in "[n]" would read back as an index into a
353
+ -- shorter name. An explicit index keeps it literal: "x[1][1]".
354
+ if ordinal == nil and string.match(node.Name, "%[%d+%]$") then
355
+ ordinal = 1
356
+ end
346
357
  table.insert(
347
358
  segments,
348
359
  1,
@@ -566,29 +566,11 @@ local DESCRIBERS: { [string]: Describer } = {
566
566
 
567
567
  --[[
568
568
  Everything below was added after the first pass over this table, and each
569
- one spent time showing as the fallback -- "Anim preview", "Data set",
569
+ one spent time showing as the fallback -- "Data set",
570
570
  "Terrain fill". That form is honest but says nothing about the subject,
571
571
  which is the whole point of this module: the panel is watched to see WHAT
572
572
  an agent is touching, and 26 of 72 operations were declining to say.
573
573
  ]]
574
- ["anim.read"] = function(params)
575
- local name = leaf(params.assetId)
576
- return if name ~= nil then "Read the animation " .. name else "Read an animation"
577
- end,
578
- ["anim.build"] = function(params)
579
- local frames = count(params.keyframes)
580
- return string.format("Build an animation from %s", plural(frames, "keyframe"))
581
- end,
582
- ["anim.preview"] = function(params)
583
- local rig = leaf(params.rig) or "a rig"
584
- if params.op == "stop" then
585
- return "Clear the pose on " .. rig
586
- end
587
- local at = tonumber(params.at)
588
- return if at ~= nil
589
- then string.format("Pose %s at %.2fs", rig, at)
590
- else "Pose " .. rig
591
- end,
592
574
 
593
575
  ["data.list"] = function(params)
594
576
  local store = params.store
@@ -771,7 +753,6 @@ local KINDS: { [string]: string } = {
771
753
  ]]
772
754
  terrain = "write",
773
755
  generate = "write",
774
- anim = "write",
775
756
  data = "write",
776
757
  }
777
758
 
@@ -67,6 +67,20 @@ local function confirm(target: LuaSourceContainer, produced: string?)
67
67
  return
68
68
  end
69
69
 
70
+ --[[
71
+ No document, no race. The overwrite comes from an editor tab that was
72
+ loading while we wrote; a tab opened afterwards loads the new source. So
73
+ a script nobody has open is confirmed by one read, instead of paying the
74
+ settle -- which cost every written script 200ms, one after another, on
75
+ every script_edit and script_create.
76
+ ]]
77
+ local found, document = pcall(function()
78
+ return ScriptEditorService:FindScriptDocument(target)
79
+ end)
80
+ if found and document == nil and ScriptEdit.read(target) == produced then
81
+ return
82
+ end
83
+
70
84
  -- Three rounds: the race is with one load, so a single rewrite settles it,
71
85
  -- and a script that discards three identical writes is failing for a reason
72
86
  -- repeating will not fix.
@@ -403,6 +403,11 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
403
403
  return true, Vector2.new(numbers[1], numbers[2]), nil
404
404
  end
405
405
  if valueType == "Color3" then
406
+ -- "#FF8800", the way colours are usually written everywhere else.
407
+ local hex = if kind == "string" then string.match(text, "^%s*#(%x%x%x%x%x%x)%s*$") else nil
408
+ if hex then
409
+ return true, Color3.fromHex(hex), nil
410
+ end
406
411
  if #numbers < 3 then
407
412
  return false, nil, 'expected three 0-1 components, e.g. "1, 0.5, 0"'
408
413
  end
@@ -435,6 +440,18 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
435
440
  return true, UDim.new(numbers[1], numbers[2]), nil
436
441
  end
437
442
  if valueType == "CFrame" then
443
+ -- Twelve numbers are a full matrix -- what `tostring(cframe)` prints. Read
444
+ -- as "position | rotation" they gave a silently wrong orientation.
445
+ if #numbers == 12 then
446
+ return true,
447
+ CFrame.new(
448
+ numbers[1], numbers[2], numbers[3],
449
+ numbers[4], numbers[5], numbers[6],
450
+ numbers[7], numbers[8], numbers[9],
451
+ numbers[10], numbers[11], numbers[12]
452
+ ),
453
+ nil
454
+ end
438
455
  if #numbers >= 6 then
439
456
  return true,
440
457
  CFrame.new(numbers[1], numbers[2], numbers[3])
@@ -263,6 +263,12 @@ function TextEdit.apply(path: string, source: string, edits: { Edit }): string
263
263
  end
264
264
 
265
265
  local text = table.concat(lines, "\n")
266
+ -- `toLines` drops the final newline, so it is put back here. Without this
267
+ -- every edit -- even a find/replace in the middle -- stripped it silently,
268
+ -- and a `find` that included it could never match.
269
+ if string.sub(source, -1) == "\n" and #lines > 0 then
270
+ text ..= "\n"
271
+ end
266
272
 
267
273
  for _, edit in findEdits do
268
274
  local needle = edit.find :: string
@@ -122,6 +122,14 @@ local function collect(entries: { any }, className: string, wantInherited: boole
122
122
  deprecated += 1
123
123
  continue
124
124
  end
125
+ -- No permits at all means no script can touch it: Part's old `shap` and
126
+ -- `shape` aliases, or engine-only calls like GetPhysicsCost. The engine
127
+ -- does not flag those as deprecated, so they are held back here too.
128
+ local permits = if typeof(entry) == "table" then (entry :: any).Permits else nil
129
+ if typeof(permits) == "table" and next(permits) == nil then
130
+ deprecated += 1
131
+ continue
132
+ end
125
133
  local own = typeof(entry) ~= "table" or tostring((entry :: any).Owner) == className
126
134
  if not own then
127
135
  inherited += 1
@@ -21,6 +21,7 @@ local RunService = game:GetService("RunService")
21
21
 
22
22
  local Dispatch = require(script.Parent.Parent.Dispatch)
23
23
  local Emulation = require(script.Parent.Parent.Emulation)
24
+ local Paths = require(script.Parent.Parent.Paths)
24
25
  local Png = require(script.Parent.Parent.Png)
25
26
 
26
27
  -- Wide enough to read a GUI label, small enough that the encode stays quick and
@@ -445,7 +446,98 @@ end
445
446
  and read in another: the id is all that travels, and this is the reading
446
447
  half, which needs plugin identity and therefore an editor session.
447
448
  ]]
448
- local function encode(contentId: string, width: number, context: string): { [string]: any }
449
+ -- In capture pixels, or in viewport pixels when `viewportWidth` says so.
450
+ export type Region = { x: number, y: number, width: number, height: number, viewportWidth: number? }
451
+
452
+ --[[
453
+ The full-resolution path: the captured pixels themselves, cropped to `region`
454
+ when one is given, compressed for the wire and scaled down in Node.
455
+
456
+ The engine resample below is bilinear, which only averages the pixels it
457
+ merges at exactly 2x -- beyond that it skips source pixels, and a 1661-wide
458
+ viewport scaled to 800 lost thin edges and broke small GUI text apart. Node
459
+ scales with a box filter instead, where every source pixel counts. Measured
460
+ cost of sending the full frame: ~60ms of a ~550ms call, most of which is the
461
+ engine's own capture.
462
+
463
+ Returns nil when this Studio cannot do it (no EncodingService), so the caller
464
+ falls back to the engine resample.
465
+ ]]
466
+ local function encodeFull(
467
+ image: any,
468
+ sourceWidth: number,
469
+ sourceHeight: number,
470
+ width: number,
471
+ context: string,
472
+ region: Region?
473
+ ): { [string]: any }?
474
+ local x, y, w, h = 0, 0, sourceWidth, sourceHeight
475
+ if region then
476
+ -- Viewport and capture pixels differ under display scaling.
477
+ local ratio = if region.viewportWidth then sourceWidth / region.viewportWidth else 1
478
+ region = {
479
+ x = region.x * ratio,
480
+ y = region.y * ratio,
481
+ width = region.width * ratio,
482
+ height = region.height * ratio,
483
+ }
484
+ x = math.clamp(math.floor(region.x), 0, sourceWidth)
485
+ y = math.clamp(math.floor(region.y), 0, sourceHeight)
486
+ w = math.min(math.ceil(region.width), sourceWidth - x)
487
+ h = math.min(math.ceil(region.height), sourceHeight - y)
488
+ if w < 1 or h < 1 then
489
+ Dispatch.fail(
490
+ "REGION_OFFSCREEN",
491
+ string.format(
492
+ "The region (%d, %d, %d x %d) is outside the %d x %d viewport.",
493
+ region.x,
494
+ region.y,
495
+ region.width,
496
+ region.height,
497
+ sourceWidth,
498
+ sourceHeight
499
+ ),
500
+ "Aim the camera at it first with `viewport op=\"focus\"`, then take the screenshot."
501
+ )
502
+ end
503
+ end
504
+
505
+ local ok, result = pcall(function()
506
+ local rgba = image:ReadPixelsBuffer(Vector2.new(x, y), Vector2.new(w, h))
507
+ local rgb = stripAlpha(rgba, w, h)
508
+ local compressed = (EncodingService :: any):CompressBuffer(
509
+ rgb,
510
+ (Enum :: any).CompressionAlgorithm.Zstd,
511
+ COMPRESSION_LEVEL
512
+ )
513
+ return {
514
+ rgb = rgb,
515
+ packed = buffer.tostring((EncodingService :: any):Base64Encode(compressed)),
516
+ }
517
+ end)
518
+ if not ok then
519
+ return nil
520
+ end
521
+
522
+ return {
523
+ encoding = "zstd-rgb",
524
+ data = result.packed,
525
+ width = w,
526
+ height = h,
527
+ -- Node scales to this with a box filter; see above.
528
+ scaleTo = width,
529
+ sourceWidth = sourceWidth,
530
+ sourceHeight = sourceHeight,
531
+ region = if region then { x = x, y = y, width = w, height = h } else nil,
532
+ rawBytes = buffer.len(result.rgb),
533
+ bytes = #result.packed,
534
+ context = context,
535
+ black = isFlat(result.rgb),
536
+ device = Emulation.deviceId(),
537
+ }
538
+ end
539
+
540
+ local function encode(contentId: string, width: number, context: string, region: Region?): { [string]: any }
449
541
  local okImage, image = pcall(function()
450
542
  return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
451
543
  end)
@@ -460,6 +552,21 @@ local function encode(contentId: string, width: number, context: string): { [str
460
552
  local sourceWidth = math.floor(size.X)
461
553
  local sourceHeight = math.floor(size.Y)
462
554
 
555
+ local full = encodeFull(image, sourceWidth, sourceHeight, width, context, region)
556
+ if full then
557
+ pcall(function()
558
+ (image :: any):Destroy()
559
+ end)
560
+ return full
561
+ end
562
+ if region then
563
+ Dispatch.fail(
564
+ "REGION_UNSUPPORTED",
565
+ "This Studio build cannot crop a screenshot (EncodingService is missing).",
566
+ "Take the full screenshot instead, or update Studio."
567
+ )
568
+ end
569
+
463
570
  local rgb, outWidth, outHeight = resample(image, sourceWidth, sourceHeight, width)
464
571
 
465
572
  if rgb == nil then
@@ -544,6 +651,111 @@ local function encode(contentId: string, width: number, context: string): { [str
544
651
  }
545
652
  end
546
653
 
654
+ -- Room left around a zoomed subject, as a share of its size, so its edges are
655
+ -- visible rather than cut exactly at the frame.
656
+ local REGION_PADDING = 0.08
657
+
658
+ --[[
659
+ Where on screen a GUI element or a piece of the world is, in viewport pixels.
660
+
661
+ GUI positions leave out the top inset, so it is added back; a 3D subject is
662
+ the screen box around its bounding box's eight corners. A subject partly
663
+ behind the camera has no honest box, so that is refused with the fix.
664
+ ]]
665
+ local function boundsOf(target: Instance): (Vector2, Vector2)
666
+ if target:IsA("GuiObject") then
667
+ local inset = game:GetService("GuiService"):GetGuiInset()
668
+ return target.AbsolutePosition + inset, target.AbsolutePosition + target.AbsoluteSize + inset
669
+ end
670
+
671
+ local cframe: CFrame, size: Vector3
672
+ if target:IsA("BasePart") then
673
+ cframe, size = target.CFrame, target.Size
674
+ elseif target:IsA("Model") then
675
+ cframe, size = target:GetBoundingBox()
676
+ else
677
+ local low, high = Vector3.one * math.huge, -Vector3.one * math.huge
678
+ local counted = 0
679
+ for _, descendant in target:GetDescendants() do
680
+ if descendant:IsA("BasePart") then
681
+ local half = descendant.Size / 2
682
+ for _, corner in { Vector3.new(-1, -1, -1), Vector3.new(1, 1, 1), Vector3.new(-1, 1, -1), Vector3.new(1, -1, 1) } do
683
+ local point = descendant.CFrame:PointToWorldSpace(half * corner)
684
+ low, high = low:Min(point), high:Max(point)
685
+ end
686
+ counted += 1
687
+ if counted >= 2000 then
688
+ break
689
+ end
690
+ end
691
+ end
692
+ if counted == 0 then
693
+ Dispatch.fail(
694
+ "NOT_VISUAL",
695
+ string.format("%s has no GUI or parts to zoom to.", target:GetFullName()),
696
+ "Zoom to a GuiObject, a BasePart, a Model, or a folder containing parts."
697
+ )
698
+ end
699
+ cframe, size = CFrame.new((low + high) / 2), high - low
700
+ end
701
+
702
+ local camera = workspace.CurrentCamera
703
+ local low, high = Vector2.one * math.huge, -Vector2.one * math.huge
704
+ for _, sx in { -1, 1 } do
705
+ for _, sy in { -1, 1 } do
706
+ for _, sz in { -1, 1 } do
707
+ local point, _ = camera:WorldToViewportPoint(cframe:PointToWorldSpace(size / 2 * Vector3.new(sx, sy, sz)))
708
+ if point.Z <= 0 then
709
+ Dispatch.fail(
710
+ "REGION_OFFSCREEN",
711
+ string.format("%s is partly behind the camera.", target:GetFullName()),
712
+ "Aim the camera at it first with `viewport op=\"focus\"`, then take the screenshot."
713
+ )
714
+ end
715
+ local flat = Vector2.new(point.X, point.Y)
716
+ low, high = low:Min(flat), high:Max(flat)
717
+ end
718
+ end
719
+ end
720
+ return low, high
721
+ end
722
+
723
+ --[[
724
+ The part of the capture to keep, in capture pixels.
725
+
726
+ `path` zooms to an instance; `rect` is "x, y, width, height" in the pixels of
727
+ the full-resolution capture, which every screenshot caption states. A path
728
+ is resolved before the capture, so a bad one fails without taking it.
729
+ ]]
730
+ local function regionFor(params: { [string]: any }): Region?
731
+ if typeof(params.rect) == "string" and params.rect ~= "" then
732
+ local numbers = {}
733
+ for token in string.gmatch(params.rect, "-?[%d%.]+") do
734
+ table.insert(numbers, tonumber(token))
735
+ end
736
+ if #numbers ~= 4 or numbers[3] <= 0 or numbers[4] <= 0 then
737
+ Dispatch.fail("BAD_PARAMS", 'rect must be "x, y, width, height", e.g. "400, 200, 320, 180".')
738
+ end
739
+ return { x = numbers[1], y = numbers[2], width = numbers[3], height = numbers[4] }
740
+ end
741
+
742
+ if typeof(params.path) ~= "string" or params.path == "" then
743
+ return nil
744
+ end
745
+ local target = Paths.resolve(params.path)
746
+ local low, high = boundsOf(target)
747
+ local extent = high - low
748
+ local pad = Vector2.new(math.max(8, extent.X * REGION_PADDING), math.max(8, extent.Y * REGION_PADDING))
749
+ low, high = low - pad, high + pad
750
+ return {
751
+ x = low.X,
752
+ y = low.Y,
753
+ width = high.X - low.X,
754
+ height = high.Y - low.Y,
755
+ viewportWidth = workspace.CurrentCamera.ViewportSize.X,
756
+ }
757
+ end
758
+
547
759
  function Capture.screenshot(params: { [string]: any }): { [string]: any }
548
760
  --[[
549
761
  Refused rather than half-done. A playtest screenshot needs two sessions
@@ -562,7 +774,9 @@ function Capture.screenshot(params: { [string]: any }): { [string]: any }
562
774
  end
563
775
 
564
776
  local width = math.clamp(tonumber(params.width) or DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH)
565
- return encode(takeScreenshot(), width, if RunService:IsEdit() then "edit" else "playtest")
777
+ -- Before the capture, so a path that does not resolve costs no screenshot.
778
+ local region = regionFor(params)
779
+ return encode(takeScreenshot(), width, if RunService:IsEdit() then "edit" else "playtest", region)
566
780
  end
567
781
 
568
782
  --[[
@@ -579,7 +793,9 @@ function Capture.decode(params: { [string]: any }): { [string]: any }
579
793
 
580
794
  local width = math.clamp(tonumber(params.width) or DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH)
581
795
  local context = if typeof(params.context) == "string" then params.context else "playtest client"
582
- return encode(contentId :: string, width, context)
796
+ -- Only `rect` here: a path would have to be resolved in the client's view.
797
+ local region = if typeof(params.rect) == "string" then regionFor({ rect = params.rect }) else nil
798
+ return encode(contentId :: string, width, context, region)
583
799
  end
584
800
 
585
801
  function Capture.register()