@el4cteo/rbx-studio-mcp 0.6.1 → 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 (47) hide show
  1. package/dist/index.js +4 -0
  2. package/dist/index.js.map +1 -1
  3. package/dist/tools/anim.js +159 -0
  4. package/dist/tools/anim.js.map +1 -0
  5. package/dist/tools/character.js +95 -5
  6. package/dist/tools/character.js.map +1 -1
  7. package/dist/tools/data.js +173 -0
  8. package/dist/tools/data.js.map +1 -0
  9. package/dist/tools/device.js +77 -7
  10. package/dist/tools/device.js.map +1 -1
  11. package/dist/tools/discover.js +80 -4
  12. package/dist/tools/discover.js.map +1 -1
  13. package/dist/tools/exec.js +48 -1
  14. package/dist/tools/exec.js.map +1 -1
  15. package/dist/tools/input.js +35 -9
  16. package/dist/tools/input.js.map +1 -1
  17. package/dist/tools/perf.js +74 -7
  18. package/dist/tools/perf.js.map +1 -1
  19. package/dist/tools/scripts.js +51 -3
  20. package/dist/tools/scripts.js.map +1 -1
  21. package/dist/tools/world.js +317 -44
  22. package/dist/tools/world.js.map +1 -1
  23. package/package.json +74 -74
  24. package/plugin/src/Commands.luau +622 -622
  25. package/plugin/src/Config.luau +65 -65
  26. package/plugin/src/Console.luau +1909 -1843
  27. package/plugin/src/Emulation.luau +172 -0
  28. package/plugin/src/Phrase.luau +164 -16
  29. package/plugin/src/Png.luau +8 -4
  30. package/plugin/src/Serialize.luau +499 -327
  31. package/plugin/src/Undo.luau +94 -6
  32. package/plugin/src/handlers/Anim.luau +897 -0
  33. package/plugin/src/handlers/Assets.luau +587 -352
  34. package/plugin/src/handlers/Capture.luau +155 -20
  35. package/plugin/src/handlers/Character.luau +823 -361
  36. package/plugin/src/handlers/Data.luau +539 -0
  37. package/plugin/src/handlers/Device.luau +394 -139
  38. package/plugin/src/handlers/Discover.luau +685 -363
  39. package/plugin/src/handlers/Geometry.luau +127 -0
  40. package/plugin/src/handlers/Perf.luau +227 -0
  41. package/plugin/src/handlers/Scripts.luau +673 -539
  42. package/plugin/src/handlers/Session.luau +3 -0
  43. package/plugin/src/handlers/Viewport.luau +268 -0
  44. package/plugin/src/handlers/World.luau +89 -15
  45. package/plugin/src/init.server.luau +879 -875
  46. package/scripts/build-plugin.mjs +20 -0
  47. package/scripts/check-plugin.mjs +171 -124
@@ -20,6 +20,8 @@
20
20
 
21
21
  local GeometryService = game:GetService("GeometryService")
22
22
 
23
+ local AssetService = game:GetService("AssetService")
24
+
23
25
  local Dispatch = require(script.Parent.Parent.Dispatch)
24
26
  local Paths = require(script.Parent.Parent.Paths)
25
27
  local Undo = require(script.Parent.Parent.Undo)
@@ -439,8 +441,133 @@ function Geometry.sweep(params: { [string]: any }): { [string]: any }
439
441
  }
440
442
  end
441
443
 
444
+ --[[
445
+ What a mesh is actually made of.
446
+
447
+ `inspect` on a MeshPart returns a content id, a size and a collision
448
+ fidelity, and none of those answer the question anyone has about a mesh,
449
+ which is "why is this place slow". A tree that renders as 40,000 triangles
450
+ and one that renders as 400 look identical in the Explorer and identical in
451
+ the Properties panel, and the difference between them is the difference
452
+ between a place that runs on a phone and one that does not.
453
+
454
+ `CreateEditableMeshAsync` opens the real geometry. It is read here and then
455
+ dropped -- nothing is modified, nothing is saved, and the editable copy is
456
+ destroyed before the reply is built, because an EditableMesh holds its data
457
+ outside Luau's heap and inspecting a hundred parts should not accumulate a
458
+ hundred copies of them.
459
+ ]]
460
+ function Geometry.mesh(params: { [string]: any }): { [string]: any }
461
+ local paths = params.paths
462
+ if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
463
+ Dispatch.fail(
464
+ "BAD_PARAMS",
465
+ "mesh needs a non-empty `paths` array.",
466
+ 'Use `find` with selector "MeshPart" to list them.'
467
+ )
468
+ end
469
+
470
+ local items: { { [string]: any } } = {}
471
+ local failures: { string } = {}
472
+ local totalTriangles = 0
473
+
474
+ for _, path in paths :: { string } do
475
+ local okResolve, resolved = pcall(Paths.resolve, path)
476
+ if not okResolve then
477
+ table.insert(failures, string.format("%s: not found", tostring(path)))
478
+ continue
479
+ end
480
+
481
+ local instance = resolved :: Instance
482
+ if not instance:IsA("MeshPart") then
483
+ table.insert(
484
+ failures,
485
+ string.format("%s: is a %s, not a MeshPart", tostring(path), instance.ClassName)
486
+ )
487
+ continue
488
+ end
489
+
490
+ local part = instance :: MeshPart
491
+ local okMesh, mesh = pcall(function()
492
+ return (AssetService :: any):CreateEditableMeshAsync((part :: any).MeshContent)
493
+ end)
494
+ if not okMesh then
495
+ --[[
496
+ Ownership, not a bug, and worth saying so in those words.
497
+
498
+ `CreateEditableMeshAsync` will only open a mesh the signed-in
499
+ Studio user owns, that the experience owner owns, or that has been
500
+ explicitly shared with one of them. Every other mesh on the
501
+ platform -- including anything inserted from the Creator Store --
502
+ refuses with "no permission to load asset", which reads like a
503
+ broken tool rather than a rule about whose asset it is. Measured:
504
+ a catalog accessory's mesh fails this way while a mesh uploaded by
505
+ the user opens fine.
506
+ ]]
507
+ local reason = tostring(mesh)
508
+ if string.find(reason, "permission", 1, true) then
509
+ table.insert(
510
+ failures,
511
+ string.format(
512
+ "%s: its mesh belongs to someone else, so Roblox will not open it. "
513
+ .. "Only meshes owned by the signed-in Studio user or the experience "
514
+ .. "owner can be read this way.",
515
+ tostring(path)
516
+ )
517
+ )
518
+ else
519
+ table.insert(
520
+ failures,
521
+ string.format("%s: could not read its geometry (%s)", tostring(path), reason)
522
+ )
523
+ end
524
+ continue
525
+ end
526
+
527
+ local editable = mesh :: any
528
+ local okRead, row = pcall(function()
529
+ local vertices = #editable:GetVertices()
530
+ local faces = #editable:GetFaces()
531
+ local size = editable:GetSize()
532
+ return {
533
+ path = Paths.of(part),
534
+ name = part.Name,
535
+ vertices = vertices,
536
+ triangles = faces,
537
+ --[[
538
+ The mesh's own size against the size it is displayed at. A
539
+ large ratio is the usual cause of a mesh that looks fine and
540
+ costs far more than it should: the same triangles stretched
541
+ over a bigger object, or shrunk into one nobody can see.
542
+ ]]
543
+ meshSize = string.format("%.2f, %.2f, %.2f", size.X, size.Y, size.Z),
544
+ partSize = string.format("%.2f, %.2f, %.2f", part.Size.X, part.Size.Y, part.Size.Z),
545
+ collisionFidelity = tostring(part.CollisionFidelity):gsub("Enum%.CollisionFidelity%.", ""),
546
+ renderFidelity = tostring(part.RenderFidelity):gsub("Enum%.RenderFidelity%.", ""),
547
+ }
548
+ end)
549
+
550
+ -- Freed before anything else happens with the result, on both paths.
551
+ pcall(function()
552
+ editable:Destroy()
553
+ end)
554
+
555
+ if not okRead then
556
+ table.insert(failures, string.format("%s: %s", tostring(path), tostring(row)))
557
+ continue
558
+ end
559
+
560
+ local entry = row :: { [string]: any }
561
+ totalTriangles += tonumber(entry.triangles) or 0
562
+ table.insert(items, entry)
563
+ end
564
+
565
+ return { items = items, failures = failures, totalTriangles = totalTriangles }
566
+ end
567
+
442
568
  function Geometry.register()
443
569
  Dispatch.registerAll("geometry", {
570
+ mesh = Geometry.mesh,
444
571
  combine = Geometry.combine,
445
572
  fragment = Geometry.fragment,
446
573
  sweep = Geometry.sweep,
@@ -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()