@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.
- package/README.md +28 -2
- package/dist/bridge/console.js +182 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/index.js +8 -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 +235 -0
- package/dist/lib/opencloud.js.map +1 -0
- package/dist/tools/anim.js +159 -0
- package/dist/tools/anim.js.map +1 -0
- package/dist/tools/audio.js +96 -0
- package/dist/tools/audio.js.map +1 -0
- package/dist/tools/character.js +95 -5
- package/dist/tools/character.js.map +1 -1
- package/dist/tools/data.js +292 -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 +96 -2
- 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 +162 -6
- 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 +177 -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 +675 -51
- package/dist/tools/world.js.map +1 -1
- package/package.json +2 -2
- package/plugin/src/Commands.luau +31 -7
- 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 +816 -618
- package/plugin/src/Png.luau +8 -4
- package/plugin/src/Prompt.luau +965 -961
- package/plugin/src/Secret.luau +86 -0
- package/plugin/src/Serialize.luau +440 -8
- package/plugin/src/Undo.luau +94 -6
- package/plugin/src/handlers/Anim.luau +897 -0
- package/plugin/src/handlers/Assets.luau +286 -2
- package/plugin/src/handlers/Audio.luau +411 -0
- 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 +722 -450
- package/plugin/src/handlers/Instances.luau +84 -4
- 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/Spatial.luau +334 -0
- package/plugin/src/handlers/Viewport.luau +268 -0
- package/plugin/src/handlers/World.luau +89 -15
- package/plugin/src/init.server.luau +9 -1
- package/scripts/build-plugin.mjs +20 -0
- package/scripts/check-plugin.mjs +171 -124
|
@@ -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
|
|
@@ -221,10 +221,19 @@ local function takeScreenshot(): string
|
|
|
221
221
|
)
|
|
222
222
|
end
|
|
223
223
|
|
|
224
|
+
--[[
|
|
225
|
+
A frame at a time to begin with, then a slower poll.
|
|
226
|
+
|
|
227
|
+
The callback normally fires within a frame or two, and a flat 0.05s wait
|
|
228
|
+
billed that to the agent as up to 50ms of doing nothing -- on a call
|
|
229
|
+
whose whole point is that it is taken often. `task.wait()` resumes on the
|
|
230
|
+
next heartbeat, so the common case now costs a frame; after a quarter of
|
|
231
|
+
a second the shot is clearly not coming back promptly and the poll drops
|
|
232
|
+
to the cheap interval for the rest of the timeout.
|
|
233
|
+
]]
|
|
224
234
|
local waited = 0
|
|
225
235
|
while contentId == nil and waited < CAPTURE_TIMEOUT do
|
|
226
|
-
task.wait(0.05)
|
|
227
|
-
waited += 0.05
|
|
236
|
+
waited += if waited < 0.25 then task.wait() else task.wait(0.05)
|
|
228
237
|
end
|
|
229
238
|
|
|
230
239
|
if contentId == nil then
|
|
@@ -310,6 +319,125 @@ function Capture.playtestId(_params: { [string]: any }): { [string]: any }
|
|
|
310
319
|
return { contentId = tostring(result.contentId), device = Emulation.deviceId() }
|
|
311
320
|
end
|
|
312
321
|
|
|
322
|
+
--[[
|
|
323
|
+
Widest image the engine will create for us.
|
|
324
|
+
|
|
325
|
+
`AssetService:CreateEditableImage` caps at 1024 on either axis, and the
|
|
326
|
+
`width` parameter goes to 1600. Above the cap there is no destination to draw
|
|
327
|
+
into, so those calls fall back to the Luau loop rather than failing -- a
|
|
328
|
+
slower big screenshot, not a missing one.
|
|
329
|
+
]]
|
|
330
|
+
local ENGINE_SCALE_LIMIT = 1024
|
|
331
|
+
|
|
332
|
+
--[[
|
|
333
|
+
Drops alpha. RGBA in, RGB out, no scaling.
|
|
334
|
+
|
|
335
|
+
A screenshot has no alpha worth keeping and carrying it would add a quarter
|
|
336
|
+
to every byte that follows, so the channel goes here rather than on the wire.
|
|
337
|
+
|
|
338
|
+
Copied a word at a time rather than a byte at a time. Each 32-bit read takes
|
|
339
|
+
a whole pixel and each write lands three bytes further on, so the alpha byte
|
|
340
|
+
spills one place into the next pixel's slot -- where the next iteration
|
|
341
|
+
overwrites it. Only the final pixel has nothing following it, which is what
|
|
342
|
+
the one byte of slack on the buffer is for; the result is then trimmed to
|
|
343
|
+
the exact length so nothing downstream has to know about it.
|
|
344
|
+
|
|
345
|
+
Worth the trick: measured on a 799x359 frame, 16ms a byte at a time against
|
|
346
|
+
6-10ms this way, for output verified identical. That is most of what the
|
|
347
|
+
engine-side downscale costs, on a call agents make constantly.
|
|
348
|
+
]]
|
|
349
|
+
local function stripAlpha(rgba: buffer, width: number, height: number): buffer
|
|
350
|
+
local pixels = width * height
|
|
351
|
+
local length = pixels * 3
|
|
352
|
+
|
|
353
|
+
local wide = buffer.create(length + 1)
|
|
354
|
+
for index = 0, pixels - 1 do
|
|
355
|
+
buffer.writeu32(wide, index * 3, buffer.readu32(rgba, index * 4))
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
local out = buffer.create(length)
|
|
359
|
+
buffer.copy(out, 0, wide, 0, length)
|
|
360
|
+
return out
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
--[[
|
|
364
|
+
Downscales on the engine's side instead of in Luau.
|
|
365
|
+
|
|
366
|
+
This is about the picture, not the clock. `SamplingMode` defaults to bilinear,
|
|
367
|
+
so a 2x reduction averages the pixels it merges; the Luau loop it replaces
|
|
368
|
+
took every other row and column and dropped the rest, which is how one-pixel
|
|
369
|
+
GUI text and thin edges vanished from screenshots that existed to answer
|
|
370
|
+
"does this look right".
|
|
371
|
+
|
|
372
|
+
It was written expecting to be faster too, and it is not -- that guess is
|
|
373
|
+
recorded here because the measurement is the useful part. On a 1370x616 frame
|
|
374
|
+
scaled to 799: the old full-resolution read costs about 1ms, not the large
|
|
375
|
+
number the 4.4MB of RGBA suggests, and the Luau downscale that follows runs
|
|
376
|
+
in 19-22ms all in. `DrawImageTransformed` alone is 16-17ms of engine time.
|
|
377
|
+
With `stripAlpha` doing the rest a word at a time, this path lands around
|
|
378
|
+
23ms -- a couple of milliseconds behind, on a call whose round trip is
|
|
379
|
+
measured in hundreds. Paying that for legible text is the trade; pretending
|
|
380
|
+
it was free would have been the mistake.
|
|
381
|
+
|
|
382
|
+
Returns nil when the engine cannot do it -- an older Studio without
|
|
383
|
+
`CreateEditableImage`, or a target past ENGINE_SCALE_LIMIT -- and the caller
|
|
384
|
+
falls back.
|
|
385
|
+
]]
|
|
386
|
+
local function resample(
|
|
387
|
+
source: any,
|
|
388
|
+
sourceWidth: number,
|
|
389
|
+
sourceHeight: number,
|
|
390
|
+
targetWidth: number
|
|
391
|
+
): (buffer?, number, number)
|
|
392
|
+
local scale = math.min(1, targetWidth / sourceWidth)
|
|
393
|
+
local outWidth = math.max(1, math.floor(sourceWidth * scale))
|
|
394
|
+
local outHeight = math.max(1, math.floor(sourceHeight * scale))
|
|
395
|
+
|
|
396
|
+
if outWidth > ENGINE_SCALE_LIMIT or outHeight > ENGINE_SCALE_LIMIT then
|
|
397
|
+
return nil, 0, 0
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
local ok, rgb = pcall(function()
|
|
401
|
+
local canvas = (AssetService :: any):CreateEditableImage({
|
|
402
|
+
Size = Vector2.new(outWidth, outHeight),
|
|
403
|
+
})
|
|
404
|
+
--[[
|
|
405
|
+
Position is the centre, not the corner: `DrawImageTransformed` places
|
|
406
|
+
the source's PIVOT at the position given, and the pivot defaults to
|
|
407
|
+
the middle of the source image. Passing Vector2.zero here puts three
|
|
408
|
+
quarters of the frame off-canvas.
|
|
409
|
+
]]
|
|
410
|
+
local okDraw = pcall(function()
|
|
411
|
+
canvas:DrawImageTransformed(
|
|
412
|
+
Vector2.new(outWidth / 2, outHeight / 2),
|
|
413
|
+
Vector2.new(scale, scale),
|
|
414
|
+
0,
|
|
415
|
+
source,
|
|
416
|
+
{
|
|
417
|
+
CombineType = Enum.ImageCombineType.Overwrite,
|
|
418
|
+
SamplingMode = Enum.ResamplerMode.Default,
|
|
419
|
+
}
|
|
420
|
+
)
|
|
421
|
+
end)
|
|
422
|
+
if not okDraw then
|
|
423
|
+
canvas:Destroy()
|
|
424
|
+
error("draw failed", 0)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
local pixels = canvas:ReadPixelsBuffer(Vector2.zero, canvas.Size)
|
|
428
|
+
-- Freed here rather than left to the collector: an EditableImage holds
|
|
429
|
+
-- its pixels outside Luau's heap, and a screenshot every few seconds
|
|
430
|
+
-- would otherwise pile them up for as long as Studio stays open.
|
|
431
|
+
canvas:Destroy()
|
|
432
|
+
return stripAlpha(pixels, outWidth, outHeight)
|
|
433
|
+
end)
|
|
434
|
+
|
|
435
|
+
if not ok then
|
|
436
|
+
return nil, 0, 0
|
|
437
|
+
end
|
|
438
|
+
return rgb :: buffer, outWidth, outHeight
|
|
439
|
+
end
|
|
440
|
+
|
|
313
441
|
--[[
|
|
314
442
|
Turns a content id into pixels on the wire.
|
|
315
443
|
|
|
@@ -332,23 +460,30 @@ local function encode(contentId: string, width: number, context: string): { [str
|
|
|
332
460
|
local sourceWidth = math.floor(size.X)
|
|
333
461
|
local sourceHeight = math.floor(size.Y)
|
|
334
462
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
463
|
+
local rgb, outWidth, outHeight = resample(image, sourceWidth, sourceHeight, width)
|
|
464
|
+
|
|
465
|
+
if rgb == nil then
|
|
466
|
+
--[[
|
|
467
|
+
Both arguments are required. Called with none it reports "expects 2
|
|
468
|
+
arguments" rather than defaulting to the whole image, which is worth
|
|
469
|
+
stating because every other read on this object takes none.
|
|
470
|
+
]]
|
|
471
|
+
local okPixels, pixels = pcall(function()
|
|
472
|
+
return (image :: any):ReadPixelsBuffer(Vector2.zero, size)
|
|
473
|
+
end)
|
|
474
|
+
if not okPixels then
|
|
475
|
+
Dispatch.fail(
|
|
476
|
+
"CAPTURE_UNREADABLE",
|
|
477
|
+
string.format("The screenshot's pixels could not be read: %s", tostring(pixels))
|
|
478
|
+
)
|
|
479
|
+
end
|
|
480
|
+
rgb, outWidth, outHeight = Png.downscaleToRgb(pixels, sourceWidth, sourceHeight, width)
|
|
348
481
|
end
|
|
349
482
|
|
|
350
|
-
|
|
351
|
-
|
|
483
|
+
-- On the scaled image, not the source: the question is whether anything was
|
|
484
|
+
-- drawn, which survives the downscale, and it is a quarter of the pixels to
|
|
485
|
+
-- walk.
|
|
486
|
+
local black = isFlat(rgb :: buffer)
|
|
352
487
|
|
|
353
488
|
--[[
|
|
354
489
|
Raw pixels, compressed by the engine, assembled into a PNG by Node.
|
|
@@ -370,7 +505,7 @@ local function encode(contentId: string, width: number, context: string): { [str
|
|
|
370
505
|
]]
|
|
371
506
|
local okPacked, packed = pcall(function()
|
|
372
507
|
local compressed = (EncodingService :: any):CompressBuffer(
|
|
373
|
-
rgb,
|
|
508
|
+
rgb :: buffer,
|
|
374
509
|
(Enum :: any).CompressionAlgorithm.Zstd,
|
|
375
510
|
COMPRESSION_LEVEL
|
|
376
511
|
)
|
|
@@ -385,7 +520,7 @@ local function encode(contentId: string, width: number, context: string): { [str
|
|
|
385
520
|
height = outHeight,
|
|
386
521
|
sourceWidth = sourceWidth,
|
|
387
522
|
sourceHeight = sourceHeight,
|
|
388
|
-
rawBytes = buffer.len(rgb),
|
|
523
|
+
rawBytes = buffer.len(rgb :: buffer),
|
|
389
524
|
bytes = #(packed :: string),
|
|
390
525
|
context = context,
|
|
391
526
|
black = black,
|
|
@@ -393,7 +528,7 @@ local function encode(contentId: string, width: number, context: string): { [str
|
|
|
393
528
|
}
|
|
394
529
|
end
|
|
395
530
|
|
|
396
|
-
local png = Png.encode(rgb, outWidth, outHeight)
|
|
531
|
+
local png = Png.encode(rgb :: buffer, outWidth, outHeight)
|
|
397
532
|
|
|
398
533
|
return {
|
|
399
534
|
encoding = "png",
|