@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.
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/tools/anim.js +159 -0
- package/dist/tools/anim.js.map +1 -0
- package/dist/tools/character.js +95 -5
- package/dist/tools/character.js.map +1 -1
- package/dist/tools/data.js +173 -0
- package/dist/tools/data.js.map +1 -0
- package/dist/tools/device.js +77 -7
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +80 -4
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +48 -1
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +35 -9
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/perf.js +74 -7
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/scripts.js +51 -3
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/world.js +317 -44
- package/dist/tools/world.js.map +1 -1
- package/package.json +74 -74
- package/plugin/src/Commands.luau +622 -622
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Console.luau +1909 -1843
- package/plugin/src/Emulation.luau +172 -0
- package/plugin/src/Phrase.luau +164 -16
- package/plugin/src/Png.luau +8 -4
- package/plugin/src/Serialize.luau +499 -327
- package/plugin/src/Undo.luau +94 -6
- package/plugin/src/handlers/Anim.luau +897 -0
- package/plugin/src/handlers/Assets.luau +587 -352
- package/plugin/src/handlers/Capture.luau +155 -20
- package/plugin/src/handlers/Character.luau +823 -361
- package/plugin/src/handlers/Data.luau +539 -0
- package/plugin/src/handlers/Device.luau +394 -139
- package/plugin/src/handlers/Discover.luau +685 -363
- package/plugin/src/handlers/Geometry.luau +127 -0
- package/plugin/src/handlers/Perf.luau +227 -0
- package/plugin/src/handlers/Scripts.luau +673 -539
- package/plugin/src/handlers/Session.luau +3 -0
- package/plugin/src/handlers/Viewport.luau +268 -0
- package/plugin/src/handlers/World.luau +89 -15
- package/plugin/src/init.server.luau +879 -875
- package/scripts/build-plugin.mjs +20 -0
- 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()
|