@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
@@ -8,6 +8,12 @@
8
8
  shape. Emulation persists until it is switched off, which is exactly the kind
9
9
  of state that goes wrong quietly.
10
10
 
11
+ The network conditions Studio is shaping traffic with live here too. They are
12
+ the same kind of state for the same reason: set once, invisible on screen,
13
+ and they stay until something turns them off. A place that "feels laggy" with
14
+ a forgotten 400ms delay on it is exactly the bug this file exists to stop
15
+ people chasing.
16
+
11
17
  `StudioDeviceSimulatorService` is undocumented on its return shapes, so what
12
18
  is here was read off a live session. Two things are worth knowing:
13
19
 
@@ -21,8 +27,172 @@
21
27
 
22
28
  local StudioDeviceSimulatorService = game:GetService("StudioDeviceSimulatorService")
23
29
 
30
+ --[[
31
+ `NetworkSettings` is not a `game` service; it hangs off `settings()`.
32
+
33
+ `game:GetService("NetworkSettings")` throws outright, which is worth writing
34
+ down because every other service in this codebase is reached that way. The
35
+ traffic-shaping properties on it -- delay, jitter, loss, in each direction --
36
+ are PluginSecurity, so they are readable and writable here and nowhere near a
37
+ running game.
38
+
39
+ Guarded: it is reached through a `pcall` at every use rather than captured
40
+ once, because a Studio old enough to lack the properties should cost the
41
+ network feature, not the whole `device` tool.
42
+ ]]
43
+ local function networkSettings(): any?
44
+ local ok, service = pcall(function()
45
+ return (settings() :: any):GetService("NetworkSettings")
46
+ end)
47
+ if ok and service ~= nil then
48
+ return service
49
+ end
50
+ return nil
51
+ end
52
+
24
53
  local Emulation = {}
25
54
 
55
+ Emulation.networkSettings = networkSettings
56
+
57
+ --[[
58
+ The six traffic-shaping properties, in the order a person reads them.
59
+
60
+ Inbound is what the client receives and is what a laggy connection feels
61
+ like; outbound is what it sends, which is what makes a player's own actions
62
+ arrive late to everyone else. Both are shaped separately by Studio, so both
63
+ are carried separately here.
64
+ ]]
65
+ --[[
66
+ The memory ceiling this session set, if any.
67
+
68
+ Session-local by necessity: see the note in `Emulation.network`. A cap set by
69
+ Studio's own toolbar before the plugin loaded is invisible to this, which is
70
+ the honest failure -- reporting a cap that is not there was the alternative,
71
+ and it was worse.
72
+ ]]
73
+ local memoryCap: number? = nil
74
+
75
+ local NETWORK_FIELDS = {
76
+ { key = "inLatency", property = "InboundNetworkMinDelayMs", scale = 1 },
77
+ { key = "inJitter", property = "InboundNetworkJitterMs", scale = 1 },
78
+ { key = "inLoss", property = "InboundNetworkLossPercent", scale = 100 },
79
+ { key = "outLatency", property = "OutboundNetworkMinDelayMs", scale = 1 },
80
+ { key = "outJitter", property = "OutboundNetworkJitterMs", scale = 1 },
81
+ { key = "outLoss", property = "OutboundNetworkLossPercent", scale = 100 },
82
+ }
83
+
84
+ --[[
85
+ What the engine will actually accept, measured rather than assumed.
86
+
87
+ None of this is documented and all three of them bite. Writes outside these
88
+ ranges SUCCEED and are silently clamped, so a preset asking for 2% loss was
89
+ reporting 0.5 back and the only way to notice was to read the value again --
90
+ which is how this was found.
91
+
92
+ * Loss is a FRACTION, despite `LossPercent` in the name: it tops out at 0.5,
93
+ and 0.5 is half the packets, not half a percent. Everything above this
94
+ layer speaks in percent because that is what a person means by "2% loss",
95
+ so the conversion happens at the edges -- `scale` above on the way out, and
96
+ `LOSS_LIMIT` on the way in.
97
+ * Delay and jitter both stop at 1000ms. A second of latency is already past
98
+ anything a real connection does, so this costs nothing but honesty.
99
+ ]]
100
+ local LOSS_LIMIT = 0.5
101
+ local DELAY_LIMIT = 1000
102
+
103
+ Emulation.LOSS_LIMIT_PERCENT = LOSS_LIMIT * 100
104
+ Emulation.DELAY_LIMIT = DELAY_LIMIT
105
+
106
+ --[[
107
+ What Studio is currently doing to network traffic.
108
+
109
+ `shaping` is the field callers should branch on: every value being zero is
110
+ the normal, un-emulated state, and reporting six zeroes without saying so
111
+ makes "is anything on" a question the caller has to work out for itself.
112
+ ]]
113
+ function Emulation.network(): { [string]: any }
114
+ local service = networkSettings()
115
+ if service == nil then
116
+ return { available = false, shaping = false }
117
+ end
118
+
119
+ local state: { [string]: any } = { available = true }
120
+ local shaping = false
121
+ for _, field in NETWORK_FIELDS do
122
+ local ok, value = pcall(function()
123
+ return (service :: any)[field.property]
124
+ end)
125
+ local number = if ok then tonumber(value) or 0 else 0
126
+ --[[
127
+ Scaled into the caller's units, then rounded to two places. These are
128
+ 32-bit floats: 2% loss is stored as 0.02 and reads back as
129
+ 0.019999999552965164, and reporting that instead of 2 makes a
130
+ perfectly applied setting look like a failed one.
131
+ ]]
132
+ number *= field.scale
133
+ state[field.key] = math.round(number * 100) / 100
134
+ if number > 0 then
135
+ shaping = true
136
+ end
137
+ end
138
+
139
+ --[[
140
+ Reported, but deliberately not counted as shaping.
141
+
142
+ `EmulatedTotalMemoryInMB` does not read back 0 when nothing is emulated:
143
+ it answers with the machine's real memory -- measured, 16159 on the
144
+ session this was written against. Treating "greater than zero" as a cap
145
+ therefore declared every untouched Studio to be emulating a low-memory
146
+ device, and `studio_status` told the agent traffic was being degraded on
147
+ a session where nothing had ever been set.
148
+
149
+ There is no read that distinguishes a cap from the true figure, so the
150
+ cap is remembered instead, by the one thing that sets it.
151
+ ]]
152
+ local okMemory, memory = pcall(function()
153
+ return (service :: any).EmulatedTotalMemoryInMB
154
+ end)
155
+ if okMemory then
156
+ state.memoryMB = math.floor(tonumber(memory) or 0)
157
+ end
158
+
159
+ if memoryCap ~= nil and memoryCap > 0 then
160
+ state.memoryCapMB = memoryCap
161
+ shaping = true
162
+ end
163
+
164
+ state.shaping = shaping
165
+ return state
166
+ end
167
+
168
+ --[[
169
+ Records the memory ceiling this session asked for.
170
+
171
+ Held here rather than in the handler because `network` writes it and
172
+ `Emulation.network` reports it, and a value those two disagree about is worse
173
+ than no value at all. nil or 0 means no cap.
174
+ ]]
175
+ function Emulation.setMemoryCap(megabytes: number?)
176
+ memoryCap = if megabytes ~= nil and megabytes > 0 then megabytes else nil
177
+ end
178
+
179
+ --[[
180
+ The short form for `studio_status`, or nil when traffic is untouched.
181
+
182
+ Separate from the device summary rather than folded into it: a session can be
183
+ shaping traffic without emulating a device, and the device summary returns
184
+ nil in exactly that case.
185
+ ]]
186
+ function Emulation.networkSummary(): { [string]: any }?
187
+ local state = Emulation.network()
188
+ if state.shaping ~= true then
189
+ return nil
190
+ end
191
+ state.note = 'Studio is degrading network traffic on purpose. `device op="network" '
192
+ .. 'preset="clear"` restores it.'
193
+ return state
194
+ end
195
+
26
196
  --[[
27
197
  The resolution as it actually appears, rather than as the panel is specified.
28
198
 
@@ -128,6 +298,8 @@ function Emulation.state(): { [string]: any }
128
298
  state.scalingMode = tostring(scaling)
129
299
  end
130
300
 
301
+ state.network = Emulation.network()
302
+
131
303
  return state
132
304
  end
133
305
 
@@ -272,9 +272,6 @@ local DESCRIBERS: { [string]: Describer } = {
272
272
  return "Search the place"
273
273
  end,
274
274
 
275
- ["script.debugSet"] = function()
276
- return "Set breakpoints"
277
- end,
278
275
  ["debug.set"] = function(params)
279
276
  local breakpoints = params.breakpoints
280
277
  local total = count(breakpoints)
@@ -481,19 +478,6 @@ local DESCRIBERS: { [string]: Describer } = {
481
478
  ["studio.ping"] = function()
482
479
  return "Ping"
483
480
  end,
484
- ["studio.transport"] = function(params)
485
- local mode = params.mode
486
- return if typeof(mode) == "string" and mode ~= ""
487
- then string.format("Switch to %s transport", mode)
488
- else "Check the transport"
489
- end,
490
-
491
- --[[
492
- The one a person actually watches: a live playtest is the point where
493
- "what is the AI doing" stops being abstract, and a console line reading
494
- "Input send" says nothing a bystander could act on. One step names the
495
- key or point; several collapse to a count, same as every other batch op.
496
- ]]
497
481
  ["input.send"] = function(params)
498
482
  local steps = params.steps
499
483
  local total = count(steps)
@@ -535,6 +519,158 @@ local DESCRIBERS: { [string]: Describer } = {
535
519
  else "Measure the scene"
536
520
  end,
537
521
 
522
+ --[[
523
+ Everything below was added after the first pass over this table, and each
524
+ one spent time showing as the fallback -- "Anim preview", "Data set",
525
+ "Terrain fill". That form is honest but says nothing about the subject,
526
+ which is the whole point of this module: the panel is watched to see WHAT
527
+ an agent is touching, and 26 of 72 operations were declining to say.
528
+ ]]
529
+ ["anim.read"] = function(params)
530
+ local name = leaf(params.assetId)
531
+ return if name ~= nil then "Read the animation " .. name else "Read an animation"
532
+ end,
533
+ ["anim.build"] = function(params)
534
+ local frames = count(params.keyframes)
535
+ return string.format("Build an animation from %s", plural(frames, "keyframe"))
536
+ end,
537
+ ["anim.preview"] = function(params)
538
+ local rig = leaf(params.rig) or "a rig"
539
+ if params.op == "stop" then
540
+ return "Clear the pose on " .. rig
541
+ end
542
+ local at = tonumber(params.at)
543
+ return if at ~= nil
544
+ then string.format("Pose %s at %.2fs", rig, at)
545
+ else "Pose " .. rig
546
+ end,
547
+
548
+ ["data.list"] = function(params)
549
+ local store = params.store
550
+ if typeof(store) == "string" and store ~= "" then
551
+ return string.format("List the keys in %s", store)
552
+ end
553
+ return "List the data stores"
554
+ end,
555
+ ["data.get"] = function(params)
556
+ return string.format("Read %s from %s", tostring(params.key), tostring(params.store))
557
+ end,
558
+ ["data.versions"] = function(params)
559
+ return string.format("Read the history of %s", tostring(params.key))
560
+ end,
561
+ ["data.set"] = function(params)
562
+ return string.format("SAVE over %s in %s", tostring(params.key), tostring(params.store))
563
+ end,
564
+ ["data.remove"] = function(params)
565
+ return string.format("DELETE %s from %s", tostring(params.key), tostring(params.store))
566
+ end,
567
+
568
+ ["discover.tags"] = function(params)
569
+ local where = leaf(params.path)
570
+ return if where ~= nil then "List the tags used in " .. where else "List every tag in use"
571
+ end,
572
+
573
+ ["viewport.ui"] = function()
574
+ return "Check the interface for layout faults"
575
+ end,
576
+ ["viewport.textbounds"] = function(params)
577
+ local name = leaf(params.path)
578
+ return if name ~= nil then "Measure the text in " .. name else "Measure some text"
579
+ end,
580
+
581
+ ["perf.audit"] = function()
582
+ return "Look for references that point at nothing"
583
+ end,
584
+
585
+ ["assets.audio"] = function(params)
586
+ local keyword = params.keyword
587
+ return if typeof(keyword) == "string" and keyword ~= ""
588
+ then string.format('Search audio for "%s"', keyword)
589
+ else "Search the audio library"
590
+ end,
591
+ ["assets.peek"] = function(params)
592
+ return string.format("Look inside asset %s without inserting it", tostring(params.assetId))
593
+ end,
594
+ ["assets.bake"] = function(params)
595
+ return "Bake " .. subjectOf(params.paths, "instance")
596
+ end,
597
+
598
+ ["character.path"] = function(params)
599
+ local target = leaf(params.toPath)
600
+ return if target ~= nil then "Check the route to " .. target else "Check a route"
601
+ end,
602
+
603
+ ["device.network"] = function(params)
604
+ local preset = params.preset
605
+ return if typeof(preset) == "string" and preset ~= ""
606
+ then string.format("Simulate a %s connection", preset)
607
+ else "Shape the network connection"
608
+ end,
609
+
610
+ ["geometry.sweep"] = function(params)
611
+ local name = leaf(params.path)
612
+ return if name ~= nil then "Sweep the path of " .. name else "Sweep a motion volume"
613
+ end,
614
+ ["geometry.mesh"] = function(params)
615
+ return "Read the geometry of " .. subjectOf(params.paths, "mesh")
616
+ end,
617
+
618
+ ["generate.model"] = function(params)
619
+ local prompt = params.prompt
620
+ return if typeof(prompt) == "string" and prompt ~= ""
621
+ then string.format('Generate a model: "%s"', prompt)
622
+ else "Generate a model"
623
+ end,
624
+ ["generate.segment"] = function(params)
625
+ local name = leaf(params.path)
626
+ return if name ~= nil then "Cut " .. name .. " into named parts" else "Segment a mesh"
627
+ end,
628
+
629
+ ["script.open"] = function(params)
630
+ local name = leaf(params.path)
631
+ return if name ~= nil then "Open " .. name .. " in the editor" else "Open a script"
632
+ end,
633
+
634
+ ["terrain.fill"] = function(params)
635
+ --[[
636
+ The material lives inside the shapes, not beside them.
637
+
638
+ Written first against a top-level `material`, which this op has never
639
+ taken, so it always fell through to the bare "Fill terrain" -- seen in
640
+ the panel for a call that laid 50 voxels of sand. The parameters a
641
+ describer reads have to be the ones the handler reads.
642
+ ]]
643
+ local shapes = params.shapes
644
+ if typeof(shapes) == "table" and #(shapes :: { any }) > 0 then
645
+ local list = shapes :: { any }
646
+ local first = list[1]
647
+ local material = if typeof(first) == "table" then first.material else nil
648
+ local named = typeof(material) == "string" and material ~= ""
649
+ if #list == 1 then
650
+ return if named
651
+ then string.format("Fill terrain with %s", material)
652
+ else "Fill terrain"
653
+ end
654
+ return if named
655
+ then string.format("Fill %s, starting with %s", plural(#list, "terrain shape"), material)
656
+ else string.format("Fill %s", plural(#list, "terrain shape"))
657
+ end
658
+ return "Fill terrain"
659
+ end,
660
+ ["terrain.clear"] = function()
661
+ return "CLEAR terrain"
662
+ end,
663
+ ["terrain.replace"] = function(params)
664
+ return string.format(
665
+ "Replace %s terrain with %s",
666
+ tostring(params.from or "one material"),
667
+ tostring(params.to or "another")
668
+ )
669
+ end,
670
+ ["terrain.stats"] = function()
671
+ return "Measure the terrain"
672
+ end,
673
+
538
674
  ["device.list"] = function()
539
675
  return "List devices"
540
676
  end,
@@ -576,6 +712,18 @@ local KINDS: { [string]: string } = {
576
712
  perf = "debug",
577
713
  api = "read",
578
714
  device = "read",
715
+ --[[
716
+ Absent groups defaulted to "read", which is the wrong way round for a
717
+ default: a missing entry made `terrain.clear` and `data.set` -- one of
718
+ which erases the map and the other of which overwrites a player's save
719
+ with no undo -- show in the panel with the same weight as an inspect.
720
+ A kind is a warning, so the ones that change things are named here
721
+ explicitly.
722
+ ]]
723
+ terrain = "write",
724
+ generate = "write",
725
+ anim = "write",
726
+ data = "write",
579
727
  }
580
728
 
581
729
  function Phrase.kindOf(op: string): string
@@ -207,10 +207,14 @@ end
207
207
  --[[
208
208
  Nearest-neighbour downscale, RGBA in and RGB out.
209
209
 
210
- Nearest rather than averaged on purpose. A screenshot is read for what is in
211
- it -- is the part there, is the GUI covering it, is the material wrong -- and
212
- averaging softens exactly the thin edges and one-pixel text that carry that.
213
- It is also a single read per output pixel instead of four.
210
+ The fallback path. `Capture` scales on the engine side now -- see
211
+ `Capture.resample` -- because this loop is six buffer calls per output pixel
212
+ in Luau and nearest-neighbour is the wrong filter for the job: at the usual
213
+ 2x reduction it does not "keep thin edges", it deletes every other row and
214
+ column outright, which is exactly where one-pixel GUI text lives. This stays
215
+ for the cases the engine path cannot take: a Studio without
216
+ `CreateEditableImage`, or a target wider than the 1024 an EditableImage can
217
+ be created at.
214
218
  ]]
215
219
  function Png.downscaleToRgb(rgba: buffer, width: number, height: number, targetWidth: number): (buffer, number, number)
216
220
  local scale = math.min(1, targetWidth / width)