@el4cteo/rbx-studio-mcp 0.6.5 → 0.6.8
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/README.md +28 -2
- package/dist/bridge/console.js +182 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/cloudassets.js +233 -0
- package/dist/lib/cloudassets.js.map +1 -0
- package/dist/lib/credentials.js +180 -0
- package/dist/lib/credentials.js.map +1 -0
- package/dist/lib/livedata.js +325 -0
- package/dist/lib/livedata.js.map +1 -0
- package/dist/lib/liveluau.js +83 -0
- package/dist/lib/liveluau.js.map +1 -0
- package/dist/lib/liveops.js +358 -0
- package/dist/lib/liveops.js.map +1 -0
- package/dist/lib/opencloud.js +290 -0
- package/dist/lib/opencloud.js.map +1 -0
- package/dist/tools/audio.js +96 -0
- package/dist/tools/audio.js.map +1 -0
- package/dist/tools/data.js +133 -3
- package/dist/tools/data.js.map +1 -1
- package/dist/tools/exec.js +56 -1
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/scripts.js +124 -4
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/spatial.js +135 -0
- package/dist/tools/spatial.js.map +1 -0
- package/dist/tools/universe.js +182 -0
- package/dist/tools/universe.js.map +1 -0
- package/dist/tools/upload.js +294 -0
- package/dist/tools/upload.js.map +1 -0
- package/dist/tools/world.js +361 -10
- package/dist/tools/world.js.map +1 -1
- package/package.json +74 -74
- package/plugin/src/Commands.luau +646 -622
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Phrase.luau +816 -766
- package/plugin/src/Prompt.luau +965 -961
- package/plugin/src/Secret.luau +86 -0
- package/plugin/src/Serialize.luau +759 -499
- package/plugin/src/handlers/Assets.luau +636 -587
- package/plugin/src/handlers/Audio.luau +411 -0
- package/plugin/src/handlers/Geometry.luau +722 -577
- package/plugin/src/handlers/Instances.luau +84 -4
- package/plugin/src/handlers/Spatial.luau +334 -0
- package/plugin/src/init.server.luau +883 -879
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
The audio graph: wiring AudioPlayers to whatever plays them.
|
|
4
|
+
|
|
5
|
+
Roblox's modern audio API is not one instance with a Play method. It is a
|
|
6
|
+
signal graph: an `AudioPlayer` holds the asset, an `AudioEmitter` puts sound
|
|
7
|
+
in the world or an `AudioDeviceOutput` sends it straight to the player's
|
|
8
|
+
speakers, effects sit in between, and NONE of them are connected until a
|
|
9
|
+
`Wire` joins two named pins. A place can hold a perfectly configured
|
|
10
|
+
AudioPlayer with the right asset, the right volume and `Playing` true, and be
|
|
11
|
+
completely silent, because the one instance that carries sound between them
|
|
12
|
+
was never made.
|
|
13
|
+
|
|
14
|
+
`create` can already build each of those instances -- that is not the gap.
|
|
15
|
+
The gap is that getting sound out requires knowing the pin names, knowing
|
|
16
|
+
which of the eight-odd sink classes fits the case, and getting the wire's
|
|
17
|
+
direction right, and a wrong guess produces silence rather than an error.
|
|
18
|
+
So this file does not wrap the engine's audio API; it wraps the knowledge of
|
|
19
|
+
how to assemble it.
|
|
20
|
+
|
|
21
|
+
Old `Sound` instances still work and are still the shorter path for a plain
|
|
22
|
+
one-off noise. Reach for this when the case needs what Sound cannot do:
|
|
23
|
+
effects, per-listener mixing, or an emitter fed by several sources.
|
|
24
|
+
]]
|
|
25
|
+
|
|
26
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
27
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
28
|
+
local Serialize = require(script.Parent.Parent.Serialize)
|
|
29
|
+
local Undo = require(script.Parent.Parent.Undo)
|
|
30
|
+
|
|
31
|
+
local Audio = {}
|
|
32
|
+
|
|
33
|
+
-- Effects usable in the middle of a chain: exactly one Input and one Output, so
|
|
34
|
+
-- they can be spliced in without the caller naming pins.
|
|
35
|
+
local EFFECTS: { [string]: boolean } = {
|
|
36
|
+
AudioFader = true,
|
|
37
|
+
AudioEqualizer = true,
|
|
38
|
+
AudioCompressor = true,
|
|
39
|
+
AudioReverb = true,
|
|
40
|
+
AudioEcho = true,
|
|
41
|
+
AudioDistortion = true,
|
|
42
|
+
AudioPitchShifter = true,
|
|
43
|
+
AudioChorus = true,
|
|
44
|
+
AudioFlanger = true,
|
|
45
|
+
AudioLimiter = true,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
local function pinsOf(instance: Instance): ({ string }, { string })
|
|
49
|
+
local okIn, inputs = pcall(function()
|
|
50
|
+
return (instance :: any):GetInputPins()
|
|
51
|
+
end)
|
|
52
|
+
local okOut, outputs = pcall(function()
|
|
53
|
+
return (instance :: any):GetOutputPins()
|
|
54
|
+
end)
|
|
55
|
+
return (if okIn then inputs else {}), (if okOut then outputs else {})
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
local function isAudio(instance: Instance): boolean
|
|
59
|
+
local inputs, outputs = pinsOf(instance)
|
|
60
|
+
return #inputs > 0 or #outputs > 0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
--[[
|
|
64
|
+
Joins two pins with a Wire.
|
|
65
|
+
|
|
66
|
+
The pin names are checked against the instances' own `GetInputPins` and
|
|
67
|
+
`GetOutputPins` rather than against a table here, because that list comes
|
|
68
|
+
from the running engine and this file would go stale. A wrong pin name is
|
|
69
|
+
the failure worth catching: the engine accepts any string, the Wire reports
|
|
70
|
+
`Connected = false`, and the only symptom is silence.
|
|
71
|
+
|
|
72
|
+
The wire is parented to the TARGET. Nothing in the engine requires that, but
|
|
73
|
+
a wire has to live somewhere and scattering them makes a graph unreadable in
|
|
74
|
+
the Explorer; keeping each one with the thing it feeds means the chain reads
|
|
75
|
+
backwards from the speaker, which is how anyone debugging silence walks it.
|
|
76
|
+
]]
|
|
77
|
+
local function connect(
|
|
78
|
+
source: Instance,
|
|
79
|
+
target: Instance,
|
|
80
|
+
sourcePin: string?,
|
|
81
|
+
targetPin: string?
|
|
82
|
+
): (Wire, string?)
|
|
83
|
+
local _, sourceOutputs = pinsOf(source)
|
|
84
|
+
local targetInputs, _ = pinsOf(target)
|
|
85
|
+
|
|
86
|
+
if #sourceOutputs == 0 then
|
|
87
|
+
Dispatch.fail(
|
|
88
|
+
"BAD_PARAMS",
|
|
89
|
+
string.format("%s has no output pins, so nothing can come out of it.", source.ClassName)
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
if #targetInputs == 0 then
|
|
93
|
+
Dispatch.fail(
|
|
94
|
+
"BAD_PARAMS",
|
|
95
|
+
string.format("%s has no input pins, so nothing can go into it.", target.ClassName)
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
local fromPin = sourcePin or (if table.find(sourceOutputs, "Output") then "Output" else sourceOutputs[1])
|
|
100
|
+
local toPin = targetPin or (if table.find(targetInputs, "Input") then "Input" else targetInputs[1])
|
|
101
|
+
|
|
102
|
+
if not table.find(sourceOutputs, fromPin) then
|
|
103
|
+
Dispatch.fail(
|
|
104
|
+
"BAD_PARAMS",
|
|
105
|
+
string.format('%s has no output pin named "%s".', source.ClassName, fromPin),
|
|
106
|
+
string.format("It has: %s", table.concat(sourceOutputs, ", "))
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
if not table.find(targetInputs, toPin) then
|
|
110
|
+
Dispatch.fail(
|
|
111
|
+
"BAD_PARAMS",
|
|
112
|
+
string.format('%s has no input pin named "%s".', target.ClassName, toPin),
|
|
113
|
+
string.format("It has: %s", table.concat(targetInputs, ", "))
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
local wire = Instance.new("Wire")
|
|
118
|
+
wire.SourceInstance = source
|
|
119
|
+
wire.SourceName = fromPin
|
|
120
|
+
wire.TargetInstance = target
|
|
121
|
+
wire.TargetName = toPin
|
|
122
|
+
wire.Parent = target
|
|
123
|
+
|
|
124
|
+
--[[
|
|
125
|
+
`Connected` is read back rather than assumed. It is the engine's own
|
|
126
|
+
verdict on whether this wire carries anything, and it is the difference
|
|
127
|
+
between "wired" and "wired correctly" -- the whole reason silence is so
|
|
128
|
+
hard to debug by hand.
|
|
129
|
+
]]
|
|
130
|
+
local warning: string? = nil
|
|
131
|
+
if not wire.Connected then
|
|
132
|
+
warning = string.format(
|
|
133
|
+
"The wire from %s.%s to %s.%s reports Connected = false.",
|
|
134
|
+
source.ClassName,
|
|
135
|
+
fromPin,
|
|
136
|
+
target.ClassName,
|
|
137
|
+
toPin
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
return wire, warning
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
--[[
|
|
144
|
+
Wires two existing instances together.
|
|
145
|
+
]]
|
|
146
|
+
function Audio.wire(params: { [string]: any }): { [string]: any }
|
|
147
|
+
local fromPath, toPath = params.from, params.to
|
|
148
|
+
if typeof(fromPath) ~= "string" or typeof(toPath) ~= "string" then
|
|
149
|
+
Dispatch.fail("BAD_PARAMS", "`from` and `to` are both paths to audio instances.")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
local source = Paths.resolve(fromPath)
|
|
153
|
+
local target = Paths.resolve(toPath)
|
|
154
|
+
|
|
155
|
+
local result, undoable = Undo.record("MCPAudioWire", "Wire audio", function()
|
|
156
|
+
local wire, warning = connect(
|
|
157
|
+
source,
|
|
158
|
+
target,
|
|
159
|
+
if typeof(params.fromPin) == "string" then params.fromPin else nil,
|
|
160
|
+
if typeof(params.toPin) == "string" then params.toPin else nil
|
|
161
|
+
)
|
|
162
|
+
return {
|
|
163
|
+
wire = Paths.of(wire),
|
|
164
|
+
from = Paths.of(source),
|
|
165
|
+
to = Paths.of(target),
|
|
166
|
+
connected = wire.Connected,
|
|
167
|
+
warning = warning,
|
|
168
|
+
}
|
|
169
|
+
end)
|
|
170
|
+
|
|
171
|
+
result.undoable = undoable
|
|
172
|
+
return result
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
--[[
|
|
176
|
+
Builds a whole working chain in one step.
|
|
177
|
+
|
|
178
|
+
`world` is a sound at a place: AudioPlayer -> [effects] -> AudioEmitter,
|
|
179
|
+
parented to a part, heard from wherever it is. This is the common case and
|
|
180
|
+
the one people get wrong, because it also needs an `AudioListener` somewhere
|
|
181
|
+
-- without one on the camera or the character, a correctly wired emitter is
|
|
182
|
+
still silent. Reported rather than created: where the listener belongs is a
|
|
183
|
+
game design decision, not a default.
|
|
184
|
+
|
|
185
|
+
`ui` is a sound with no position: AudioPlayer -> [effects] ->
|
|
186
|
+
AudioDeviceOutput. Menu clicks, music.
|
|
187
|
+
]]
|
|
188
|
+
function Audio.graph(params: { [string]: any }): { [string]: any }
|
|
189
|
+
local kind = tostring(params.kind or "world")
|
|
190
|
+
if kind ~= "world" and kind ~= "ui" then
|
|
191
|
+
Dispatch.fail("BAD_PARAMS", string.format("unknown kind %q", kind), 'Use "world" or "ui".')
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
local parentPath = params.parent
|
|
195
|
+
if typeof(parentPath) ~= "string" or parentPath == "" then
|
|
196
|
+
Dispatch.fail(
|
|
197
|
+
"BAD_PARAMS",
|
|
198
|
+
"`parent` says where the graph goes.",
|
|
199
|
+
'For kind="world" that is the part the sound comes from.'
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
local parent = Paths.resolve(parentPath)
|
|
203
|
+
|
|
204
|
+
if kind == "world" and not parent:IsA("BasePart") and not parent:IsA("Attachment") then
|
|
205
|
+
Dispatch.fail(
|
|
206
|
+
"BAD_PARAMS",
|
|
207
|
+
string.format("%s is a %s.", parentPath, parent.ClassName),
|
|
208
|
+
"A world sound needs somewhere in the world to come from: parent it "
|
|
209
|
+
.. 'to a BasePart or an Attachment, or use kind="ui" for a sound '
|
|
210
|
+
.. "with no position."
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
local asset = params.asset
|
|
215
|
+
if asset ~= nil and typeof(asset) ~= "string" then
|
|
216
|
+
Dispatch.fail("BAD_PARAMS", "`asset` is an id like \"rbxassetid://1234\".")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
local effects: { string } = {}
|
|
220
|
+
if params.effects ~= nil then
|
|
221
|
+
if typeof(params.effects) ~= "table" then
|
|
222
|
+
Dispatch.fail("BAD_PARAMS", "`effects` is a list of class names.")
|
|
223
|
+
end
|
|
224
|
+
for _, name in params.effects :: { any } do
|
|
225
|
+
local class = tostring(name)
|
|
226
|
+
if not EFFECTS[class] then
|
|
227
|
+
local known: { string } = {}
|
|
228
|
+
for effect in EFFECTS do
|
|
229
|
+
table.insert(known, effect)
|
|
230
|
+
end
|
|
231
|
+
table.sort(known)
|
|
232
|
+
Dispatch.fail(
|
|
233
|
+
"BAD_PARAMS",
|
|
234
|
+
string.format("%s is not an audio effect that can sit in a chain.", class),
|
|
235
|
+
string.format("Use one of: %s", table.concat(known, ", "))
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
table.insert(effects, class)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
local name = if typeof(params.name) == "string" and params.name ~= "" then params.name else "Audio"
|
|
243
|
+
|
|
244
|
+
local result, undoable = Undo.record("MCPAudioGraph", "Build audio graph", function()
|
|
245
|
+
local created: { string } = {}
|
|
246
|
+
local warnings: { string } = {}
|
|
247
|
+
|
|
248
|
+
local player = Instance.new("AudioPlayer")
|
|
249
|
+
player.Name = name
|
|
250
|
+
if asset then
|
|
251
|
+
--[[
|
|
252
|
+
`AudioContent` is the modern property and takes a Content, not a
|
|
253
|
+
string -- assigning the id directly throws. `Asset` is the older
|
|
254
|
+
ContentId spelling and still works; this uses the one the running
|
|
255
|
+
engine actually has rather than picking a side.
|
|
256
|
+
]]
|
|
257
|
+
local okContent = pcall(function()
|
|
258
|
+
player.AudioContent = Content.fromUri(asset :: string)
|
|
259
|
+
end)
|
|
260
|
+
if not okContent then
|
|
261
|
+
pcall(function()
|
|
262
|
+
(player :: any).Asset = asset
|
|
263
|
+
end)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
player.Parent = parent
|
|
267
|
+
table.insert(created, Paths.of(player))
|
|
268
|
+
|
|
269
|
+
-- The chain is built front to back, each link wired to the one before.
|
|
270
|
+
local previous: Instance = player
|
|
271
|
+
for _, class in effects do
|
|
272
|
+
local effect = Instance.new(class)
|
|
273
|
+
effect.Name = class:gsub("^Audio", "")
|
|
274
|
+
effect.Parent = parent
|
|
275
|
+
local _, warning = connect(previous, effect)
|
|
276
|
+
if warning then
|
|
277
|
+
table.insert(warnings, warning)
|
|
278
|
+
end
|
|
279
|
+
table.insert(created, Paths.of(effect))
|
|
280
|
+
previous = effect
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
local sink = Instance.new(if kind == "world" then "AudioEmitter" else "AudioDeviceOutput")
|
|
284
|
+
sink.Name = if kind == "world" then "Emitter" else "Output"
|
|
285
|
+
sink.Parent = parent
|
|
286
|
+
local _, sinkWarning = connect(previous, sink)
|
|
287
|
+
if sinkWarning then
|
|
288
|
+
table.insert(warnings, sinkWarning)
|
|
289
|
+
end
|
|
290
|
+
table.insert(created, Paths.of(sink))
|
|
291
|
+
|
|
292
|
+
local note: string? = nil
|
|
293
|
+
if kind == "world" then
|
|
294
|
+
--[[
|
|
295
|
+
An emitter with no listener anywhere is the single most common
|
|
296
|
+
way a correct graph stays silent, and it is invisible from the
|
|
297
|
+
emitter's own properties. Checked and said plainly.
|
|
298
|
+
]]
|
|
299
|
+
local listeners = 0
|
|
300
|
+
for _, descendant in game:GetDescendants() do
|
|
301
|
+
if descendant:IsA("AudioListener") then
|
|
302
|
+
listeners += 1
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
if listeners == 0 then
|
|
306
|
+
note = "Nothing in this place has an AudioListener, so no emitter can "
|
|
307
|
+
.. "be heard. Put one on the camera or the character -- where it "
|
|
308
|
+
.. "goes decides what the player hears, so it is not created here."
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
kind = kind,
|
|
314
|
+
player = Paths.of(player),
|
|
315
|
+
sink = Paths.of(sink),
|
|
316
|
+
created = created,
|
|
317
|
+
warnings = warnings,
|
|
318
|
+
note = note,
|
|
319
|
+
}
|
|
320
|
+
end)
|
|
321
|
+
|
|
322
|
+
result.undoable = undoable
|
|
323
|
+
return result
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
--[[
|
|
327
|
+
Reads an existing graph back.
|
|
328
|
+
|
|
329
|
+
Walks whatever audio instances live under a path and reports every wire in
|
|
330
|
+
and out of each, which is the view the Explorer will not give: a Wire shows
|
|
331
|
+
as a child of one instance and names the other two only in its properties, so
|
|
332
|
+
following a chain by hand means clicking through every node.
|
|
333
|
+
]]
|
|
334
|
+
function Audio.inspect(params: { [string]: any }): { [string]: any }
|
|
335
|
+
local path = params.path
|
|
336
|
+
local scope: Instance = if typeof(path) == "string" and path ~= ""
|
|
337
|
+
then Paths.resolve(path)
|
|
338
|
+
else game
|
|
339
|
+
|
|
340
|
+
local nodes: { { [string]: any } } = {}
|
|
341
|
+
local wires: { { [string]: any } } = {}
|
|
342
|
+
local broken = 0
|
|
343
|
+
|
|
344
|
+
local function visit(instance: Instance)
|
|
345
|
+
if instance:IsA("Wire") then
|
|
346
|
+
local source = instance.SourceInstance
|
|
347
|
+
local target = instance.TargetInstance
|
|
348
|
+
if not instance.Connected then
|
|
349
|
+
broken += 1
|
|
350
|
+
end
|
|
351
|
+
table.insert(wires, {
|
|
352
|
+
path = Paths.of(instance),
|
|
353
|
+
from = if source then string.format("%s.%s", Paths.of(source), instance.SourceName) else "(nothing)",
|
|
354
|
+
to = if target then string.format("%s.%s", Paths.of(target), instance.TargetName) else "(nothing)",
|
|
355
|
+
connected = instance.Connected,
|
|
356
|
+
})
|
|
357
|
+
elseif isAudio(instance) then
|
|
358
|
+
local entry: { [string]: any } = {
|
|
359
|
+
path = Paths.of(instance),
|
|
360
|
+
class = instance.ClassName,
|
|
361
|
+
}
|
|
362
|
+
-- Only the properties that decide whether a node is audible; the
|
|
363
|
+
-- full set is what `inspect` is for.
|
|
364
|
+
for _, property in { "Volume", "Playing", "IsPlaying", "Looping", "AudioInteractionGroup" } do
|
|
365
|
+
local ok, value = pcall(function()
|
|
366
|
+
return (instance :: any)[property]
|
|
367
|
+
end)
|
|
368
|
+
if ok and value ~= nil then
|
|
369
|
+
entry[property] = Serialize.value(value)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
table.insert(nodes, entry)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
visit(scope)
|
|
377
|
+
for _, descendant in scope:GetDescendants() do
|
|
378
|
+
visit(descendant)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
local note: string? = nil
|
|
382
|
+
if #nodes > 0 and #wires == 0 then
|
|
383
|
+
note = "There are audio instances here but no wires, so nothing carries sound "
|
|
384
|
+
.. "between them. That is silence with no error anywhere."
|
|
385
|
+
elseif broken > 0 then
|
|
386
|
+
note = string.format(
|
|
387
|
+
"%d wire(s) report Connected = false -- usually a pin name that does not "
|
|
388
|
+
.. "exist on one end, or an instance that was deleted.",
|
|
389
|
+
broken
|
|
390
|
+
)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
scope = Paths.of(scope),
|
|
395
|
+
nodes = nodes,
|
|
396
|
+
wires = wires,
|
|
397
|
+
nodeCount = #nodes,
|
|
398
|
+
wireCount = #wires,
|
|
399
|
+
note = note,
|
|
400
|
+
}
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
function Audio.register()
|
|
404
|
+
Dispatch.registerAll("audio", {
|
|
405
|
+
wire = Audio.wire,
|
|
406
|
+
graph = Audio.graph,
|
|
407
|
+
inspect = Audio.inspect,
|
|
408
|
+
})
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
return Audio
|