@el4cteo/rbx-studio-mcp 0.7.8 → 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 (50) 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/screenshot.js +55 -11
  29. package/dist/tools/screenshot.js.map +1 -1
  30. package/dist/tools/scripts.js +4 -4
  31. package/dist/tools/scripts.js.map +1 -1
  32. package/dist/tools/terrain.js +3 -3
  33. package/dist/tools/terrain.js.map +1 -1
  34. package/dist/tools/world.js +8 -8
  35. package/dist/tools/world.js.map +1 -1
  36. package/package.json +75 -75
  37. package/plugin/src/Config.luau +1 -1
  38. package/plugin/src/LogBuffer.luau +7 -0
  39. package/plugin/src/Paths.luau +15 -4
  40. package/plugin/src/Phrase.luau +1 -20
  41. package/plugin/src/ScriptEdit.luau +14 -0
  42. package/plugin/src/Serialize.luau +17 -0
  43. package/plugin/src/TextEdit.luau +6 -0
  44. package/plugin/src/handlers/Api.luau +8 -0
  45. package/plugin/src/handlers/Capture.luau +219 -3
  46. package/plugin/src/init.server.luau +0 -2
  47. package/scripts/test-tools.mjs +259 -216
  48. package/dist/tools/anim.js +0 -159
  49. package/dist/tools/anim.js.map +0 -1
  50. package/plugin/src/handlers/Anim.luau +0 -897
@@ -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()
@@ -42,7 +42,6 @@ local Viewport = require(script.handlers.Viewport)
42
42
  local Input = require(script.handlers.Input)
43
43
  local Device = require(script.handlers.Device)
44
44
  local Data = require(script.handlers.Data)
45
- local Anim = require(script.handlers.Anim)
46
45
  local Api = require(script.handlers.Api)
47
46
  local Scripts = require(script.handlers.Scripts)
48
47
  local Session = require(script.handlers.Session)
@@ -197,7 +196,6 @@ Scripts.register()
197
196
  Input.register()
198
197
  Device.register()
199
198
  Data.register()
200
- Anim.register()
201
199
  Api.register()
202
200
 
203
201
  --[[