@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.7

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 (75) hide show
  1. package/README.md +28 -2
  2. package/dist/bridge/console.js +182 -0
  3. package/dist/bridge/console.js.map +1 -1
  4. package/dist/index.js +8 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/cloudassets.js +233 -0
  7. package/dist/lib/cloudassets.js.map +1 -0
  8. package/dist/lib/credentials.js +180 -0
  9. package/dist/lib/credentials.js.map +1 -0
  10. package/dist/lib/livedata.js +325 -0
  11. package/dist/lib/livedata.js.map +1 -0
  12. package/dist/lib/liveluau.js +83 -0
  13. package/dist/lib/liveluau.js.map +1 -0
  14. package/dist/lib/liveops.js +358 -0
  15. package/dist/lib/liveops.js.map +1 -0
  16. package/dist/lib/opencloud.js +235 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/anim.js +159 -0
  19. package/dist/tools/anim.js.map +1 -0
  20. package/dist/tools/audio.js +96 -0
  21. package/dist/tools/audio.js.map +1 -0
  22. package/dist/tools/character.js +95 -5
  23. package/dist/tools/character.js.map +1 -1
  24. package/dist/tools/data.js +292 -0
  25. package/dist/tools/data.js.map +1 -0
  26. package/dist/tools/device.js +77 -7
  27. package/dist/tools/device.js.map +1 -1
  28. package/dist/tools/discover.js +80 -4
  29. package/dist/tools/discover.js.map +1 -1
  30. package/dist/tools/exec.js +96 -2
  31. package/dist/tools/exec.js.map +1 -1
  32. package/dist/tools/input.js +35 -9
  33. package/dist/tools/input.js.map +1 -1
  34. package/dist/tools/perf.js +74 -7
  35. package/dist/tools/perf.js.map +1 -1
  36. package/dist/tools/scripts.js +162 -6
  37. package/dist/tools/scripts.js.map +1 -1
  38. package/dist/tools/spatial.js +135 -0
  39. package/dist/tools/spatial.js.map +1 -0
  40. package/dist/tools/universe.js +177 -0
  41. package/dist/tools/universe.js.map +1 -0
  42. package/dist/tools/upload.js +294 -0
  43. package/dist/tools/upload.js.map +1 -0
  44. package/dist/tools/world.js +675 -51
  45. package/dist/tools/world.js.map +1 -1
  46. package/package.json +2 -2
  47. package/plugin/src/Commands.luau +31 -7
  48. package/plugin/src/Config.luau +65 -65
  49. package/plugin/src/Console.luau +1909 -1843
  50. package/plugin/src/Emulation.luau +172 -0
  51. package/plugin/src/Phrase.luau +816 -618
  52. package/plugin/src/Png.luau +8 -4
  53. package/plugin/src/Prompt.luau +965 -961
  54. package/plugin/src/Secret.luau +86 -0
  55. package/plugin/src/Serialize.luau +440 -8
  56. package/plugin/src/Undo.luau +94 -6
  57. package/plugin/src/handlers/Anim.luau +897 -0
  58. package/plugin/src/handlers/Assets.luau +286 -2
  59. package/plugin/src/handlers/Audio.luau +411 -0
  60. package/plugin/src/handlers/Capture.luau +155 -20
  61. package/plugin/src/handlers/Character.luau +823 -361
  62. package/plugin/src/handlers/Data.luau +539 -0
  63. package/plugin/src/handlers/Device.luau +394 -139
  64. package/plugin/src/handlers/Discover.luau +685 -363
  65. package/plugin/src/handlers/Geometry.luau +722 -450
  66. package/plugin/src/handlers/Instances.luau +84 -4
  67. package/plugin/src/handlers/Perf.luau +227 -0
  68. package/plugin/src/handlers/Scripts.luau +673 -539
  69. package/plugin/src/handlers/Session.luau +3 -0
  70. package/plugin/src/handlers/Spatial.luau +334 -0
  71. package/plugin/src/handlers/Viewport.luau +268 -0
  72. package/plugin/src/handlers/World.luau +89 -15
  73. package/plugin/src/init.server.luau +9 -1
  74. package/scripts/build-plugin.mjs +20 -0
  75. package/scripts/check-plugin.mjs +171 -124
@@ -116,7 +116,17 @@ end
116
116
  model appears in the Explorer complete rather than assembling itself piece by
117
117
  piece in front of the user.
118
118
  ]]
119
- local function build(spec: { [string]: any }, parent: Instance, created: { Instance })
119
+ --[[
120
+ One property that could not be applied yet, kept for a second attempt.
121
+ ]]
122
+ type Deferred = { target: Instance, name: string, spec: PropertySpec, className: string }
123
+
124
+ local function build(
125
+ spec: { [string]: any },
126
+ parent: Instance,
127
+ created: { Instance },
128
+ deferred: { Deferred }
129
+ )
120
130
  local ok, instance = pcall(Instance.new, spec.className)
121
131
  if not ok or typeof(instance) ~= "Instance" then
122
132
  Dispatch.fail(
@@ -132,11 +142,37 @@ local function build(spec: { [string]: any }, parent: Instance, created: { Insta
132
142
  target.Name = spec.name
133
143
  end
134
144
 
145
+ --[[
146
+ A property naming an instance that does not exist YET is set aside, not
147
+ failed.
148
+
149
+ The whole subtree is assembled detached and attached at the very end --
150
+ see the last line of this function -- so while a batch is being built,
151
+ nothing in it is reachable by path. That made a one-call rig impossible:
152
+ a HingeConstraint could not point at the Attachment created two lines
153
+ above it, a Motor6D could not find its Part0, and the tool's own promise
154
+ of "build a whole model in a single call" stopped exactly where joints
155
+ began. Each of those needed a second `create` call, which is also a
156
+ second undo step.
157
+
158
+ So these are retried once the batch is attached. Anything else -- a
159
+ misspelled enum, a malformed Vector3 -- still fails immediately, because
160
+ waiting cannot make it right.
161
+ ]]
135
162
  local failures: { string } = {}
136
163
  for name, property in (spec.properties or {}) :: { [string]: PropertySpec } do
137
164
  local failure = applyProperty(target, name, property)
138
165
  if failure then
139
- table.insert(failures, failure)
166
+ if string.find(failure, Serialize.UNRESOLVED_PREFIX, 1, true) then
167
+ table.insert(deferred, {
168
+ target = target,
169
+ name = name,
170
+ spec = property,
171
+ className = tostring(spec.className),
172
+ })
173
+ else
174
+ table.insert(failures, failure)
175
+ end
140
176
  end
141
177
  end
142
178
  for _, failure in applyAttributes(target, (spec.attributes or {}) :: { [string]: any }) do
@@ -158,7 +194,7 @@ local function build(spec: { [string]: any }, parent: Instance, created: { Insta
158
194
  -- Recorded parent-first, so the response reads top-down like the Explorer.
159
195
  table.insert(created, target)
160
196
  for _, child in (spec.children or {}) :: { { [string]: any } } do
161
- build(child, target, created)
197
+ build(child, target, created, deferred)
162
198
  end
163
199
 
164
200
  target.Parent = parent
@@ -183,8 +219,52 @@ function Instances.create(params: { [string]: any }): { [string]: any }
183
219
 
184
220
  local created, recorded = Undo.record("StudioMCP.Create", "MCP create", function()
185
221
  local built: { Instance } = {}
222
+ local deferred: { Deferred } = {}
186
223
  for _, spec in specs do
187
- build(spec, Paths.resolve(spec.parent), built)
224
+ build(spec, Paths.resolve(spec.parent), built, deferred)
225
+ end
226
+
227
+ --[[
228
+ Second pass, after everything is attached.
229
+
230
+ Now every instance in the batch is reachable by path, so a reference
231
+ to a sibling resolves. A failure here is real -- the path names
232
+ something that was never going to exist -- and it fails the batch,
233
+ which cancels the recording and leaves the place untouched.
234
+ ]]
235
+ local failures: { string } = {}
236
+ for _, entry in deferred do
237
+ local failure = applyProperty(entry.target, entry.name, entry.spec)
238
+ if failure then
239
+ --[[
240
+ Stripped with find+sub, NOT gsub.
241
+
242
+ `string.gsub` takes a Lua PATTERN, and the marker is
243
+ "[unresolved] " -- which as a pattern is a character class
244
+ matching any one of u, n, r, e, s, o, l, v, d. It quietly
245
+ ate those letters out of the whole message, turning "this
246
+ property holds a BasePart" into "thiproperty holda
247
+ BasePart". Caught in QA; the message is the only thing a
248
+ reader gets here, so a mangled one is the whole failure.
249
+
250
+ The property name is already inside `failure`, so it is not
251
+ prepended a second time -- that read as "Part1: Part1: ...".
252
+ ]]
253
+ local clean = failure
254
+ local at = string.find(clean, Serialize.UNRESOLVED_PREFIX, 1, true)
255
+ if at then
256
+ clean = string.sub(clean, 1, at - 1)
257
+ .. string.sub(clean, at + #Serialize.UNRESOLVED_PREFIX)
258
+ end
259
+ table.insert(failures, string.format("%s.%s", entry.className, clean))
260
+ end
261
+ end
262
+ if #failures > 0 then
263
+ Dispatch.fail(
264
+ "BAD_PROPERTY",
265
+ string.format("Could not set %d reference(s) after the batch was built.", #failures),
266
+ table.concat(failures, "; ")
267
+ )
188
268
  end
189
269
  return built
190
270
  end)
@@ -21,6 +21,7 @@ local Stats = game:GetService("Stats")
21
21
  local Dispatch = require(script.Parent.Parent.Dispatch)
22
22
  local LogBuffer = require(script.Parent.Parent.LogBuffer)
23
23
  local Paths = require(script.Parent.Parent.Paths)
24
+ local Scope = require(script.Parent.Parent.Scope)
24
25
 
25
26
  -- The log holds thousands of lines in a busy session; an unbounded tail would
26
27
  -- swamp any context window.
@@ -506,6 +507,231 @@ function Perf.coverage(params: { [string]: any }): { [string]: any }
506
507
  }
507
508
  end
508
509
 
510
+ --[[
511
+ Every reference in the place that points at nothing.
512
+
513
+ A dead asset id is the quietest failure Roblox has. A Sound whose id was
514
+ deleted, made private, or mistyped plays silence; a Decal shows nothing; an
515
+ Animation does nothing. None of them errors, none of them warns, and the
516
+ instance looks perfectly healthy in the Explorer -- the id is a string, and
517
+ it is still a string. The only way to find them today is to play the game and
518
+ notice something missing, which nobody does reliably for the twentieth sound
519
+ effect.
520
+
521
+ `ContentProvider:PreloadAsync` settles it. Measured: a real id came back
522
+ `IsLoaded=true, TimeLength=0.632`, a dead one `IsLoaded=false, TimeLength=0`.
523
+ So this collects every asset-bearing property in the place, asks the engine
524
+ to fetch them, and reports the ones that did not arrive.
525
+
526
+ One honest cost: preloading a dead id writes a line to Studio's Output
527
+ window. That is the engine talking, not this tool, and it corroborates the
528
+ finding rather than contradicting it -- but it is the user's Output window,
529
+ so the tool says up front that it will happen.
530
+ ]]
531
+
532
+ --[[
533
+ Assets fetched in one audit.
534
+
535
+ Preloading is network work, and a place with two thousand sounds would spend
536
+ minutes on it while Studio sat still. The cap is generous enough to cover
537
+ most places whole and small enough that the worst case is seconds, and the
538
+ reply says when it stopped short rather than implying it checked everything.
539
+ ]]
540
+ local MAX_ASSETS = 200
541
+
542
+ --[[
543
+ How long the whole fetch may take before this gives up on it.
544
+
545
+ Long enough for two hundred ordinary assets over a slow connection, short
546
+ enough that a stuck fetch is a slow answer rather than a hung Studio.
547
+ ]]
548
+ local PRELOAD_SECONDS = 20
549
+
550
+ --[[
551
+ Which property on which class holds a content id.
552
+
553
+ Written out rather than discovered from the API dump, because "a string
554
+ property whose name sounds like an asset" catches `Name` and misses
555
+ `MeshContent`. The list is short, and being wrong here means reporting a
556
+ healthy place as broken.
557
+ ]]
558
+ local ASSET_FIELDS: { { className: string, property: string } } = {
559
+ { className = "Sound", property = "SoundId" },
560
+ { className = "Decal", property = "Texture" },
561
+ { className = "Texture", property = "Texture" },
562
+ { className = "ImageLabel", property = "Image" },
563
+ { className = "ImageButton", property = "Image" },
564
+ { className = "Animation", property = "AnimationId" },
565
+ { className = "Sky", property = "SkyboxUp" },
566
+ { className = "ParticleEmitter", property = "Texture" },
567
+ { className = "Beam", property = "Texture" },
568
+ { className = "Trail", property = "Texture" },
569
+ }
570
+
571
+ function Perf.audit(params: { [string]: any }): { [string]: any }
572
+ local ContentProvider = game:GetService("ContentProvider")
573
+
574
+ local unset: { { [string]: any } } = {}
575
+ local candidates: { { instance: Instance, id: string, property: string } } = {}
576
+ local disabled: { string } = {}
577
+ local duplicates: { string } = {}
578
+
579
+ local seenNames: { [string]: boolean } = {}
580
+
581
+ for _, descendant in game:GetDescendants() do
582
+ if Scope.isNoisy(descendant) then
583
+ continue
584
+ end
585
+
586
+ for _, field in ASSET_FIELDS do
587
+ if not descendant:IsA(field.className) then
588
+ continue
589
+ end
590
+ local ok, value = pcall(function()
591
+ return (descendant :: any)[field.property]
592
+ end)
593
+ if not ok or typeof(value) ~= "string" then
594
+ continue
595
+ end
596
+ if value == "" then
597
+ table.insert(unset, { path = Paths.of(descendant), property = field.property })
598
+ elseif #candidates < MAX_ASSETS then
599
+ table.insert(candidates, { instance = descendant, id = value, property = field.property })
600
+ end
601
+ break
602
+ end
603
+
604
+ --[[
605
+ `Disabled` exists on BaseScript, not on ModuleScript -- reading it off
606
+ the wrong one throws, which is how this check failed the first time it
607
+ was written. Narrowed to the class that has it.
608
+ ]]
609
+ if descendant:IsA("BaseScript") and (descendant :: any).Disabled == true then
610
+ if #disabled < 25 then
611
+ table.insert(disabled, Paths.of(descendant))
612
+ end
613
+ end
614
+
615
+ --[[
616
+ Same-named siblings, which is what breaks `WaitForChild`: it returns
617
+ whichever one the engine reaches first, and that is not stable.
618
+
619
+ Geometry is excluded, and that exclusion is the whole value of the
620
+ check. Measured on a real place: the first version reported 25
621
+ duplicates and every one of them was a wall bar, a bench or a table
622
+ leg in a blockout -- decorative parts nobody ever looks up by name.
623
+ The real findings were buried under them.
624
+
625
+ A duplicated script, RemoteEvent, value or GUI element is a different
626
+ matter: those exist to be found by name, and two of them means code
627
+ somewhere is reaching for whichever the engine happened to index
628
+ first.
629
+ ]]
630
+ local parent = descendant.Parent
631
+ if parent ~= nil and not descendant:IsA("BasePart") and not descendant:IsA("Attachment") then
632
+ local key = tostring(parent:GetDebugId(8)) .. "/" .. descendant.Name
633
+ if seenNames[key] and #duplicates < 25 then
634
+ table.insert(duplicates, Paths.of(descendant))
635
+ end
636
+ seenNames[key] = true
637
+ end
638
+ end
639
+
640
+ --[[
641
+ Preloaded in one call rather than one per asset.
642
+
643
+ `PreloadAsync` takes the whole list and fetches in parallel; asking for
644
+ them one at a time would serialise two hundred network round trips into
645
+ something that looks like a hang.
646
+ ]]
647
+ local toLoad: { Instance } = {}
648
+ for _, entry in candidates do
649
+ table.insert(toLoad, entry.instance)
650
+ end
651
+ --[[
652
+ Given a deadline, because `PreloadAsync` has none of its own.
653
+
654
+ It blocks until every id in the list resolves, and an id that resolves
655
+ slowly -- or a network that has stopped answering -- blocks it for as
656
+ long as that takes. Measured: an audit against a running playtest sat
657
+ still long enough for the request to time out and the playtest to end
658
+ with it. A tool that can stop Studio responding is worse than one that
659
+ reports less.
660
+
661
+ Run on its own thread and waited on here, so the wait can end without
662
+ the work being cancelled -- there is no way to cancel it, and pretending
663
+ otherwise would leave a thread writing into a finished request.
664
+ ]]
665
+ local preloaded = true
666
+ if #toLoad > 0 then
667
+ local done = false
668
+ task.spawn(function()
669
+ pcall(function()
670
+ ContentProvider:PreloadAsync(toLoad)
671
+ end)
672
+ done = true
673
+ end)
674
+
675
+ local waited = 0
676
+ while not done and waited < PRELOAD_SECONDS do
677
+ waited += task.wait(0.05)
678
+ end
679
+ preloaded = done
680
+ end
681
+
682
+ local dead: { { [string]: any } } = {}
683
+ for _, entry in candidates do
684
+ local instance = entry.instance
685
+ local loaded = true
686
+ if instance:IsA("Sound") then
687
+ -- A Sound that fetched has a length; one that did not is still zero.
688
+ loaded = (instance :: Sound).TimeLength > 0
689
+ else
690
+ local ok, value = pcall(function()
691
+ return (instance :: any).IsLoaded
692
+ end)
693
+ if ok and typeof(value) == "boolean" then
694
+ loaded = value
695
+ end
696
+ end
697
+ --[[
698
+ Nothing is called dead once the fetch has been abandoned.
699
+
700
+ An id that simply had not arrived yet is indistinguishable here from
701
+ one that points at nothing, and "this sound is broken" about a sound
702
+ that is fine is the one answer this check must never give.
703
+ ]]
704
+ if not loaded and not preloaded then
705
+ continue
706
+ end
707
+ if not loaded and #dead < 50 then
708
+ table.insert(dead, {
709
+ path = Paths.of(instance),
710
+ property = entry.property,
711
+ id = entry.id,
712
+ })
713
+ end
714
+ end
715
+
716
+ return {
717
+ checked = #candidates,
718
+ -- Reported beside the fetched count, because "3 checked" on a place with
719
+ -- fourteen blank ids reads as if the audit barely ran. A blank id needs
720
+ -- no fetch to be judged; it is still a reference that points nowhere.
721
+ scanned = #candidates + #unset,
722
+ truncated = #candidates >= MAX_ASSETS,
723
+ -- Said out loud, because an empty `dead` list from an abandoned fetch
724
+ -- reads exactly like a clean place.
725
+ incomplete = if not preloaded then true else nil,
726
+ dead = dead,
727
+ deadCount = #dead,
728
+ unset = unset,
729
+ unsetCount = #unset,
730
+ disabledScripts = disabled,
731
+ duplicateNames = duplicates,
732
+ }
733
+ end
734
+
509
735
  function Perf.register(host: Plugin?)
510
736
  pluginRef = host
511
737
 
@@ -649,6 +875,7 @@ function Perf.scene(params: { [string]: any }): { [string]: any }
649
875
  end
650
876
 
651
877
  Dispatch.registerAll("perf", {
878
+ audit = Perf.audit,
652
879
  console = Perf.console,
653
880
  snapshot = function()
654
881
  return Perf.snapshot()