@el4cteo/rbx-studio-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/bridge/rpc.js +243 -0
- package/dist/bridge/rpc.js.map +1 -0
- package/dist/bridge/server.js +281 -0
- package/dist/bridge/server.js.map +1 -0
- package/dist/index.js +104 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/apidump.js +269 -0
- package/dist/lib/apidump.js.map +1 -0
- package/dist/lib/errors.js +38 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/format.js +191 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/pluginbuild.js +83 -0
- package/dist/lib/pluginbuild.js.map +1 -0
- package/dist/lib/png.js +84 -0
- package/dist/lib/png.js.map +1 -0
- package/dist/lib/protocol.js +22 -0
- package/dist/lib/protocol.js.map +1 -0
- package/dist/lib/tool.js +27 -0
- package/dist/lib/tool.js.map +1 -0
- package/dist/resources.js +70 -0
- package/dist/resources.js.map +1 -0
- package/dist/tools/api.js +78 -0
- package/dist/tools/api.js.map +1 -0
- package/dist/tools/character.js +94 -0
- package/dist/tools/character.js.map +1 -0
- package/dist/tools/debug.js +211 -0
- package/dist/tools/debug.js.map +1 -0
- package/dist/tools/device.js +74 -0
- package/dist/tools/device.js.map +1 -0
- package/dist/tools/discover.js +217 -0
- package/dist/tools/discover.js.map +1 -0
- package/dist/tools/exec.js +191 -0
- package/dist/tools/exec.js.map +1 -0
- package/dist/tools/input.js +96 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/instances.js +261 -0
- package/dist/tools/instances.js.map +1 -0
- package/dist/tools/perf.js +367 -0
- package/dist/tools/perf.js.map +1 -0
- package/dist/tools/playtest.js +153 -0
- package/dist/tools/playtest.js.map +1 -0
- package/dist/tools/screenshot.js +75 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/scripts.js +316 -0
- package/dist/tools/scripts.js.map +1 -0
- package/dist/tools/session.js +152 -0
- package/dist/tools/session.js.map +1 -0
- package/dist/tools/world.js +281 -0
- package/dist/tools/world.js.map +1 -0
- package/package.json +62 -0
- package/plugin/default.project.json +6 -0
- package/plugin/src/Config.luau +59 -0
- package/plugin/src/Console.luau +657 -0
- package/plugin/src/Context.luau +35 -0
- package/plugin/src/Dispatch.luau +90 -0
- package/plugin/src/Editor.luau +142 -0
- package/plugin/src/Emulation.luau +151 -0
- package/plugin/src/LogBuffer.luau +277 -0
- package/plugin/src/Net.luau +102 -0
- package/plugin/src/Paths.luau +255 -0
- package/plugin/src/Phrase.luau +465 -0
- package/plugin/src/Png.luau +238 -0
- package/plugin/src/Scope.luau +78 -0
- package/plugin/src/ScriptEdit.luau +100 -0
- package/plugin/src/Serialize.luau +287 -0
- package/plugin/src/TextEdit.luau +296 -0
- package/plugin/src/Transport.luau +328 -0
- package/plugin/src/Undo.luau +72 -0
- package/plugin/src/Visuals.luau +710 -0
- package/plugin/src/handlers/Api.luau +242 -0
- package/plugin/src/handlers/Assets.luau +145 -0
- package/plugin/src/handlers/Capture.luau +187 -0
- package/plugin/src/handlers/Character.luau +361 -0
- package/plugin/src/handlers/Debug.luau +391 -0
- package/plugin/src/handlers/Device.luau +119 -0
- package/plugin/src/handlers/Discover.luau +289 -0
- package/plugin/src/handlers/Exec.luau +270 -0
- package/plugin/src/handlers/Geometry.luau +261 -0
- package/plugin/src/handlers/Input.luau +287 -0
- package/plugin/src/handlers/Instances.luau +389 -0
- package/plugin/src/handlers/Perf.luau +645 -0
- package/plugin/src/handlers/Playtest.luau +205 -0
- package/plugin/src/handlers/Scripts.luau +387 -0
- package/plugin/src/handlers/Session.luau +168 -0
- package/plugin/src/handlers/Viewport.luau +302 -0
- package/plugin/src/handlers/World.luau +176 -0
- package/plugin/src/init.server.luau +317 -0
- package/scripts/build-plugin.mjs +157 -0
- package/scripts/check-plugin.mjs +97 -0
- package/scripts/install-plugin.mjs +39 -0
- package/scripts/latency.mjs +201 -0
- package/scripts/locate-luau.mjs +51 -0
- package/scripts/sourcemap.mjs +58 -0
- package/scripts/test-plugin.mjs +82 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
What a class can do, answered by the engine that is running.
|
|
4
|
+
|
|
5
|
+
The server already validates property names against Roblox's published API
|
|
6
|
+
dump, fetched over HTTP from a community mirror. That works and has two
|
|
7
|
+
weaknesses: it needs the network, and it describes whatever build the mirror
|
|
8
|
+
last published rather than the Studio in front of you.
|
|
9
|
+
|
|
10
|
+
`ReflectionService` is the same information, locally, from the binary that is
|
|
11
|
+
actually executing -- 633 classes and 96 properties on Part, measured. It
|
|
12
|
+
cannot go stale and it cannot fail to download.
|
|
13
|
+
|
|
14
|
+
This answers a question an agent asks constantly and currently cannot:
|
|
15
|
+
"what do I call on this thing?" Without it the only way to learn a class's
|
|
16
|
+
surface is to find an instance of it and read every property, which needs one
|
|
17
|
+
to exist and still never lists methods or events.
|
|
18
|
+
]]
|
|
19
|
+
|
|
20
|
+
local ReflectionService = game:GetService("ReflectionService")
|
|
21
|
+
|
|
22
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
23
|
+
|
|
24
|
+
local Api = {}
|
|
25
|
+
|
|
26
|
+
-- A class listing is for orientation, not for reading end to end; 633 classes
|
|
27
|
+
-- would be most of a context window.
|
|
28
|
+
local MAX_CLASSES = 150
|
|
29
|
+
|
|
30
|
+
local function nameOf(entry: any): string
|
|
31
|
+
if typeof(entry) == "table" then
|
|
32
|
+
return tostring((entry :: any).Name)
|
|
33
|
+
end
|
|
34
|
+
return tostring(entry)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
--[[
|
|
38
|
+
The script-facing name of a type, which is not always the engine's.
|
|
39
|
+
|
|
40
|
+
Entries carry `{ EngineType, ScriptType }` and they differ where it matters:
|
|
41
|
+
an int64 is a `number` to Luau, and a caller writing code wants the second.
|
|
42
|
+
]]
|
|
43
|
+
local function typeName(spec: any): string
|
|
44
|
+
if typeof(spec) ~= "table" then
|
|
45
|
+
return "?"
|
|
46
|
+
end
|
|
47
|
+
return tostring((spec :: any).ScriptType or (spec :: any).EngineType or "?")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
--[[
|
|
51
|
+
Renders a member as something that can be typed, not just recognised.
|
|
52
|
+
|
|
53
|
+
A bare name says a method exists; a signature says how to call it, which is
|
|
54
|
+
the actual question. `Humanoid.AddAccessory` is a guess until you know it
|
|
55
|
+
takes one Instance.
|
|
56
|
+
]]
|
|
57
|
+
local function signature(entry: any): string
|
|
58
|
+
local name = nameOf(entry)
|
|
59
|
+
local parameters = (entry :: any).Parameters
|
|
60
|
+
if typeof(parameters) ~= "table" then
|
|
61
|
+
-- A property. Its type is the useful half.
|
|
62
|
+
local kind = (entry :: any).Type
|
|
63
|
+
return if kind ~= nil then string.format("%s: %s", name, typeName(kind)) else name
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
local rendered: { string } = {}
|
|
67
|
+
for _, parameter in parameters :: { any } do
|
|
68
|
+
table.insert(rendered, string.format("%s: %s", tostring(parameter.Name), typeName(parameter.Type)))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
local returns = (entry :: any).ReturnType
|
|
72
|
+
local suffix = if returns ~= nil and typeName(returns) ~= "Void"
|
|
73
|
+
then " -> " .. typeName(returns)
|
|
74
|
+
else ""
|
|
75
|
+
return string.format("%s(%s)%s", name, table.concat(rendered, ", "), suffix)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
--[[
|
|
79
|
+
The deprecation notice on a member, if the engine carries one.
|
|
80
|
+
|
|
81
|
+
`Display.DeprecationMessage` is how the reflection data marks a member as
|
|
82
|
+
superseded, and Instance carries eight: `clone`, `remove`, `getChildren` and
|
|
83
|
+
the rest of the lowercase aliases retired years ago. They are a fifth of
|
|
84
|
+
everything Instance appears to offer, and they still run -- so an agent that
|
|
85
|
+
picks one from the list gets working code and a deprecation warning in the
|
|
86
|
+
user's output, which is exactly the sort of thing this tool exists to stop.
|
|
87
|
+
]]
|
|
88
|
+
local function deprecation(entry: any): string?
|
|
89
|
+
if typeof(entry) ~= "table" then
|
|
90
|
+
return nil
|
|
91
|
+
end
|
|
92
|
+
local display = (entry :: any).Display
|
|
93
|
+
if typeof(display) ~= "table" then
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
local message = (display :: any).DeprecationMessage
|
|
97
|
+
return if typeof(message) == "string" and message ~= "" then message else nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
--[[
|
|
101
|
+
Renders the members worth showing, and counts the ones left out.
|
|
102
|
+
|
|
103
|
+
Two things are held back. Deprecated members always: nothing should be
|
|
104
|
+
written against them. Inherited members unless asked for: ProximityPrompt has
|
|
105
|
+
2 methods of its own and 42 from Instance and Object, so a flat alphabetical
|
|
106
|
+
list buries `InputHoldBegin` among boilerplate the caller already knows.
|
|
107
|
+
|
|
108
|
+
Both are counted rather than silently dropped, because "it has no such
|
|
109
|
+
method" and "it has it, from Instance" and "it has it, but do not use it" are
|
|
110
|
+
three different answers and the caller needs to tell them apart.
|
|
111
|
+
|
|
112
|
+
Sorted so two calls agree: an agent comparing a class across engine versions
|
|
113
|
+
should see what changed, not a reshuffle.
|
|
114
|
+
]]
|
|
115
|
+
local function collect(entries: { any }, className: string, wantInherited: boolean): ({ string }, number, number)
|
|
116
|
+
local kept: { string } = {}
|
|
117
|
+
local inherited = 0
|
|
118
|
+
local deprecated = 0
|
|
119
|
+
|
|
120
|
+
for _, entry in entries do
|
|
121
|
+
if deprecation(entry) ~= nil then
|
|
122
|
+
deprecated += 1
|
|
123
|
+
continue
|
|
124
|
+
end
|
|
125
|
+
local own = typeof(entry) ~= "table" or tostring((entry :: any).Owner) == className
|
|
126
|
+
if not own then
|
|
127
|
+
inherited += 1
|
|
128
|
+
end
|
|
129
|
+
if own or wantInherited then
|
|
130
|
+
table.insert(kept, signature(entry))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
table.sort(kept)
|
|
135
|
+
return kept, inherited, deprecated
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
function Api.describe(params: { [string]: any }): { [string]: any }
|
|
139
|
+
local className = params.className
|
|
140
|
+
if typeof(className) ~= "string" or className == "" then
|
|
141
|
+
Dispatch.fail("BAD_PARAMS", "describe needs a `className`, e.g. \"TweenService\".")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
local okClass, class = pcall(function()
|
|
145
|
+
return (ReflectionService :: any):GetClass(className, {})
|
|
146
|
+
end)
|
|
147
|
+
if not okClass or class == nil then
|
|
148
|
+
Dispatch.fail(
|
|
149
|
+
"NO_SUCH_CLASS",
|
|
150
|
+
string.format("The engine has no class called %q.", className),
|
|
151
|
+
"Class names are case-sensitive. Use `api op=\"classes\"` with a `contains` filter to find it."
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
local result: { [string]: any } = {
|
|
156
|
+
className = nameOf(class),
|
|
157
|
+
superclass = if typeof(class) == "table" then tostring((class :: any).Superclass) else nil,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
local wanted = params.include
|
|
161
|
+
local include = if typeof(wanted) == "table" then wanted :: { string } else { "properties", "methods", "events" }
|
|
162
|
+
local function asked(what: string): boolean
|
|
163
|
+
for _, item in include do
|
|
164
|
+
if item == what then
|
|
165
|
+
return true
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
return false
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
-- Asked for explicitly, because the filter argument these calls accept is
|
|
172
|
+
-- ignored: passing Inherited = false, IncludeInherited = false and
|
|
173
|
+
-- Deprecated = false each returned exactly the same 26 properties as {}.
|
|
174
|
+
-- The Owner field is the only thing that actually separates them.
|
|
175
|
+
local exact = tostring(result.className)
|
|
176
|
+
local wantInherited = params.inherited == true
|
|
177
|
+
|
|
178
|
+
local sources = {
|
|
179
|
+
{ key = "properties", method = "GetPropertiesOfClass" },
|
|
180
|
+
{ key = "methods", method = "GetMethodsOfClass" },
|
|
181
|
+
{ key = "events", method = "GetEventsOfClass" },
|
|
182
|
+
}
|
|
183
|
+
for _, source in sources do
|
|
184
|
+
if not asked(source.key) then
|
|
185
|
+
continue
|
|
186
|
+
end
|
|
187
|
+
local ok, entries = pcall(function()
|
|
188
|
+
return (ReflectionService :: any)[source.method](ReflectionService, className, {})
|
|
189
|
+
end)
|
|
190
|
+
if not ok then
|
|
191
|
+
continue
|
|
192
|
+
end
|
|
193
|
+
local members, inherited, deprecated = collect(entries :: { any }, exact, wantInherited)
|
|
194
|
+
result[source.key] = members
|
|
195
|
+
if inherited > 0 and not wantInherited then
|
|
196
|
+
result[source.key .. "Inherited"] = inherited
|
|
197
|
+
end
|
|
198
|
+
if deprecated > 0 then
|
|
199
|
+
result[source.key .. "Deprecated"] = deprecated
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
return result
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
function Api.classes(params: { [string]: any }): { [string]: any }
|
|
207
|
+
local ok, classes = pcall(function()
|
|
208
|
+
return (ReflectionService :: any):GetClasses({})
|
|
209
|
+
end)
|
|
210
|
+
if not ok then
|
|
211
|
+
Dispatch.fail("REFLECTION_FAILED", string.format("Could not list classes: %s", tostring(classes)))
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
local needle = if typeof(params.contains) == "string" then string.lower(params.contains) else nil
|
|
215
|
+
local matched: { string } = {}
|
|
216
|
+
local total = 0
|
|
217
|
+
for _, entry in classes :: { any } do
|
|
218
|
+
local name = nameOf(entry)
|
|
219
|
+
if needle == nil or string.find(string.lower(name), needle, 1, true) ~= nil then
|
|
220
|
+
total += 1
|
|
221
|
+
if #matched < MAX_CLASSES then
|
|
222
|
+
table.insert(matched, name)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
table.sort(matched)
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
classes = matched,
|
|
230
|
+
matched = total,
|
|
231
|
+
truncated = if total > #matched then total - #matched else nil,
|
|
232
|
+
}
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
function Api.register()
|
|
236
|
+
Dispatch.registerAll("api", {
|
|
237
|
+
describe = Api.describe,
|
|
238
|
+
classes = Api.classes,
|
|
239
|
+
})
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
return Api
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Inserting Creator Store assets into the place.
|
|
4
|
+
|
|
5
|
+
The search half of this lives on the server, not here: Node already has
|
|
6
|
+
internet access and Roblox's toolbox endpoints answer unauthenticated, while
|
|
7
|
+
a plugin making outbound HTTP needs the user to grant permission per domain.
|
|
8
|
+
Doing it server-side means search works the moment the server starts, and the
|
|
9
|
+
plugin only ever talks to the one host it already talks to.
|
|
10
|
+
|
|
11
|
+
So this file does the half only Studio can do: turn an asset id into real
|
|
12
|
+
instances in the data model, under a recording so one insert is one Ctrl+Z.
|
|
13
|
+
]]
|
|
14
|
+
|
|
15
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
16
|
+
local Paths = require(script.Parent.Parent.Paths)
|
|
17
|
+
local Undo = require(script.Parent.Parent.Undo)
|
|
18
|
+
|
|
19
|
+
local Assets = {}
|
|
20
|
+
|
|
21
|
+
--[[
|
|
22
|
+
Counts scripts anywhere inside what was inserted.
|
|
23
|
+
|
|
24
|
+
Free models carrying scripts are the oldest hazard on the platform, and the
|
|
25
|
+
agent inserting one has no other way to know. This is reported on every
|
|
26
|
+
insert rather than only when asked, because the caller who most needs to see
|
|
27
|
+
it is the one who did not think to look.
|
|
28
|
+
]]
|
|
29
|
+
local function countScripts(root: Instance): (number, { string })
|
|
30
|
+
local total = 0
|
|
31
|
+
local names: { string } = {}
|
|
32
|
+
for _, descendant in root:GetDescendants() do
|
|
33
|
+
if descendant:IsA("LuaSourceContainer") then
|
|
34
|
+
total += 1
|
|
35
|
+
if #names < 10 then
|
|
36
|
+
table.insert(names, string.format("%s (%s)", descendant.Name, descendant.ClassName))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
return total, names
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
function Assets.insert(params: { [string]: any }): { [string]: any }
|
|
44
|
+
local assetId = tonumber(params.assetId)
|
|
45
|
+
if assetId == nil or assetId <= 0 then
|
|
46
|
+
Dispatch.fail("BAD_PARAMS", "insert needs a numeric `assetId`.")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
local parent = if typeof(params.parent) == "string" and params.parent ~= ""
|
|
50
|
+
then Paths.resolve(params.parent)
|
|
51
|
+
else workspace
|
|
52
|
+
|
|
53
|
+
--[[
|
|
54
|
+
`game:GetObjects`, not `InsertService:LoadAsset`.
|
|
55
|
+
|
|
56
|
+
LoadAsset is the documented route and it refuses everything here: every
|
|
57
|
+
public model tried, from several creators, came back "User is not
|
|
58
|
+
authorized to access Asset". It enforces ownership, which makes it useful
|
|
59
|
+
for a game loading its own assets and useless for inserting from the
|
|
60
|
+
Creator Store -- the entire point of this tool.
|
|
61
|
+
|
|
62
|
+
GetObjects is the plugin-security path Studio's own toolbox uses, and it
|
|
63
|
+
loaded the same ids without complaint. It returns an array of top-level
|
|
64
|
+
instances rather than a wrapper model, so there is nothing to unwrap.
|
|
65
|
+
]]
|
|
66
|
+
local ok, loaded = pcall(function()
|
|
67
|
+
return game:GetObjects("rbxassetid://" .. assetId)
|
|
68
|
+
end)
|
|
69
|
+
if not ok then
|
|
70
|
+
Dispatch.fail(
|
|
71
|
+
"INSERT_FAILED",
|
|
72
|
+
string.format("Could not load asset %d: %s", assetId, tostring(loaded)),
|
|
73
|
+
"The asset may be private, deleted, or not a model."
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
local children = loaded :: { Instance }
|
|
78
|
+
if #children == 0 then
|
|
79
|
+
Dispatch.fail("EMPTY_ASSET", string.format("Asset %d contained nothing.", assetId))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
-- Counted across every root, since an asset can arrive as several instances
|
|
83
|
+
-- and a script hiding in the second one counts exactly as much.
|
|
84
|
+
local scriptCount = 0
|
|
85
|
+
local scriptNames: { string } = {}
|
|
86
|
+
for _, child in children do
|
|
87
|
+
local count, names = countScripts(child)
|
|
88
|
+
scriptCount += count
|
|
89
|
+
for _, name in names do
|
|
90
|
+
if #scriptNames < 10 then
|
|
91
|
+
table.insert(scriptNames, name)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
if child:IsA("LuaSourceContainer") then
|
|
95
|
+
scriptCount += 1
|
|
96
|
+
if #scriptNames < 10 then
|
|
97
|
+
table.insert(scriptNames, string.format("%s (%s)", child.Name, child.ClassName))
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
local inserted: { string } = {}
|
|
103
|
+
local _, undoable = Undo.record("MCPInsertAsset", "MCP insert asset", function()
|
|
104
|
+
for _, child in children do
|
|
105
|
+
if typeof(params.name) == "string" and params.name ~= "" and #children == 1 then
|
|
106
|
+
child.Name = params.name
|
|
107
|
+
end
|
|
108
|
+
child.Parent = parent
|
|
109
|
+
table.insert(inserted, Paths.of(child))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
-- Position after parenting, so PrimaryPart and pivot are settled.
|
|
113
|
+
if typeof(params.position) == "string" and params.position ~= "" then
|
|
114
|
+
local x, y, z = string.match(params.position, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
|
|
115
|
+
if x then
|
|
116
|
+
local target = CFrame.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
|
|
117
|
+
for _, child in children do
|
|
118
|
+
pcall(function()
|
|
119
|
+
if child:IsA("Model") then
|
|
120
|
+
(child :: Model):PivotTo(target)
|
|
121
|
+
elseif child:IsA("BasePart") then
|
|
122
|
+
(child :: BasePart).CFrame = target
|
|
123
|
+
end
|
|
124
|
+
end)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end)
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
inserted = inserted,
|
|
132
|
+
assetId = assetId,
|
|
133
|
+
scriptCount = scriptCount,
|
|
134
|
+
scripts = if scriptCount > 0 then scriptNames else nil,
|
|
135
|
+
undoable = undoable,
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
function Assets.register()
|
|
140
|
+
Dispatch.registerAll("assets", {
|
|
141
|
+
insert = Assets.insert,
|
|
142
|
+
})
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
return Assets
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Screenshots, so an agent can look at the place instead of inferring it.
|
|
4
|
+
|
|
5
|
+
Everything else this server exposes reads the data model: names, properties,
|
|
6
|
+
numbers. None of that answers "does it look right", which for a 3D medium is
|
|
7
|
+
most of the question. A part can sit at the correct position, anchored, the
|
|
8
|
+
right size, and still be inside a wall.
|
|
9
|
+
|
|
10
|
+
The path is four steps, none of them optional. `CaptureService` hands back a
|
|
11
|
+
temporary content id rather than pixels; `AssetService` turns that id into an
|
|
12
|
+
EditableImage; the image gives up raw RGBA; and PNG and base64 are written by
|
|
13
|
+
hand because the engine has neither. See Png.luau.
|
|
14
|
+
]]
|
|
15
|
+
|
|
16
|
+
local AssetService = game:GetService("AssetService")
|
|
17
|
+
local CaptureService = game:GetService("CaptureService")
|
|
18
|
+
local EncodingService = game:GetService("EncodingService")
|
|
19
|
+
local RunService = game:GetService("RunService")
|
|
20
|
+
|
|
21
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
22
|
+
local Emulation = require(script.Parent.Parent.Emulation)
|
|
23
|
+
local Png = require(script.Parent.Parent.Png)
|
|
24
|
+
|
|
25
|
+
-- Wide enough to read a GUI label, small enough that the encode stays quick and
|
|
26
|
+
-- the reply does not dominate the conversation it is part of.
|
|
27
|
+
local DEFAULT_WIDTH = 800
|
|
28
|
+
local MAX_WIDTH = 1600
|
|
29
|
+
local MIN_WIDTH = 160
|
|
30
|
+
|
|
31
|
+
-- The callback has never taken close to this. It exists so a capture that never
|
|
32
|
+
-- calls back fails with something an agent can act on rather than hanging the
|
|
33
|
+
-- session until the request deadline.
|
|
34
|
+
local CAPTURE_TIMEOUT = 10
|
|
35
|
+
|
|
36
|
+
-- Zstd level. 3 is its default and already gets most of the win on screen
|
|
37
|
+
-- content; the higher levels cost Studio's main thread for a few percent.
|
|
38
|
+
local COMPRESSION_LEVEL = 3
|
|
39
|
+
|
|
40
|
+
local Capture = {}
|
|
41
|
+
|
|
42
|
+
--[[
|
|
43
|
+
Takes the shot and waits for the id.
|
|
44
|
+
|
|
45
|
+
`CaptureService:CaptureScreenshot` answers through a callback rather than
|
|
46
|
+
yielding, so this bridges the two: the handler is already running on its own
|
|
47
|
+
task and is free to wait.
|
|
48
|
+
]]
|
|
49
|
+
local function takeScreenshot(): string
|
|
50
|
+
local contentId: string? = nil
|
|
51
|
+
local failed: string? = nil
|
|
52
|
+
|
|
53
|
+
local ok, err = pcall(function()
|
|
54
|
+
CaptureService:CaptureScreenshot(function(id: string)
|
|
55
|
+
contentId = id
|
|
56
|
+
end)
|
|
57
|
+
end)
|
|
58
|
+
if not ok then
|
|
59
|
+
failed = tostring(err)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if failed ~= nil then
|
|
63
|
+
Dispatch.fail(
|
|
64
|
+
"CAPTURE_REFUSED",
|
|
65
|
+
string.format("Studio refused to take a screenshot: %s", failed)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
local waited = 0
|
|
70
|
+
while contentId == nil and waited < CAPTURE_TIMEOUT do
|
|
71
|
+
task.wait(0.05)
|
|
72
|
+
waited += 0.05
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if contentId == nil then
|
|
76
|
+
Dispatch.fail(
|
|
77
|
+
"CAPTURE_TIMEOUT",
|
|
78
|
+
string.format("Studio did not return a screenshot within %ds.", CAPTURE_TIMEOUT),
|
|
79
|
+
"The viewport may be hidden or Studio may be busy; try again."
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
return contentId :: any
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
function Capture.screenshot(params: { [string]: any }): { [string]: any }
|
|
87
|
+
local width = math.clamp(tonumber(params.width) or DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH)
|
|
88
|
+
|
|
89
|
+
local contentId = takeScreenshot()
|
|
90
|
+
|
|
91
|
+
local okImage, image = pcall(function()
|
|
92
|
+
return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
|
|
93
|
+
end)
|
|
94
|
+
if not okImage then
|
|
95
|
+
Dispatch.fail(
|
|
96
|
+
"CAPTURE_UNREADABLE",
|
|
97
|
+
string.format("The screenshot could not be opened for reading: %s", tostring(image))
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
local size = (image :: any).Size
|
|
102
|
+
local sourceWidth = math.floor(size.X)
|
|
103
|
+
local sourceHeight = math.floor(size.Y)
|
|
104
|
+
|
|
105
|
+
--[[
|
|
106
|
+
Both arguments are required. Called with none it reports "expects 2
|
|
107
|
+
arguments" rather than defaulting to the whole image, which is worth
|
|
108
|
+
stating because every other read on this object takes none.
|
|
109
|
+
]]
|
|
110
|
+
local okPixels, pixels = pcall(function()
|
|
111
|
+
return (image :: any):ReadPixelsBuffer(Vector2.zero, size)
|
|
112
|
+
end)
|
|
113
|
+
if not okPixels then
|
|
114
|
+
Dispatch.fail(
|
|
115
|
+
"CAPTURE_UNREADABLE",
|
|
116
|
+
string.format("The screenshot's pixels could not be read: %s", tostring(pixels))
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
local rgb, outWidth, outHeight = Png.downscaleToRgb(pixels, sourceWidth, sourceHeight, width)
|
|
121
|
+
|
|
122
|
+
--[[
|
|
123
|
+
Raw pixels, compressed by the engine, assembled into a PNG by Node.
|
|
124
|
+
|
|
125
|
+
The plugin used to write the whole PNG itself, and could only write a bad
|
|
126
|
+
one: Studio has no deflate, so `Png.encode` emitted zlib *stored* blocks
|
|
127
|
+
-- the format's "compression not applied" escape hatch -- and every
|
|
128
|
+
screenshot travelled and landed at full uncompressed size. It was also
|
|
129
|
+
base64-ing by hand, a table lookup per byte in Luau.
|
|
130
|
+
|
|
131
|
+
`EncodingService` has both, natively. Zstd is the only algorithm the
|
|
132
|
+
engine exposes and PNG cannot use it, so the split is: the plugin
|
|
133
|
+
compresses the pixels for the wire, and Node -- which has real zlib --
|
|
134
|
+
decompresses and writes a properly deflated PNG. The side with the
|
|
135
|
+
compressor does the compressing.
|
|
136
|
+
|
|
137
|
+
Guarded rather than assumed: EncodingService is recent, and a Studio
|
|
138
|
+
without it should cost a bigger screenshot, not a failed one.
|
|
139
|
+
]]
|
|
140
|
+
local okPacked, packed = pcall(function()
|
|
141
|
+
local compressed = (EncodingService :: any):CompressBuffer(
|
|
142
|
+
rgb,
|
|
143
|
+
(Enum :: any).CompressionAlgorithm.Zstd,
|
|
144
|
+
COMPRESSION_LEVEL
|
|
145
|
+
)
|
|
146
|
+
return buffer.tostring((EncodingService :: any):Base64Encode(compressed))
|
|
147
|
+
end)
|
|
148
|
+
|
|
149
|
+
if okPacked then
|
|
150
|
+
return {
|
|
151
|
+
encoding = "zstd-rgb",
|
|
152
|
+
data = packed,
|
|
153
|
+
width = outWidth,
|
|
154
|
+
height = outHeight,
|
|
155
|
+
sourceWidth = sourceWidth,
|
|
156
|
+
sourceHeight = sourceHeight,
|
|
157
|
+
rawBytes = buffer.len(rgb),
|
|
158
|
+
bytes = #(packed :: string),
|
|
159
|
+
context = if RunService:IsEdit() then "edit" else "playtest",
|
|
160
|
+
device = Emulation.deviceId(),
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
local png = Png.encode(rgb, outWidth, outHeight)
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
encoding = "png",
|
|
168
|
+
data = Png.base64(png),
|
|
169
|
+
width = outWidth,
|
|
170
|
+
height = outHeight,
|
|
171
|
+
sourceWidth = sourceWidth,
|
|
172
|
+
sourceHeight = sourceHeight,
|
|
173
|
+
bytes = buffer.len(png),
|
|
174
|
+
-- Which window this came from. A screenshot carries no indication of
|
|
175
|
+
-- whether it shows the editor or a running game, and the two look alike.
|
|
176
|
+
context = if RunService:IsEdit() then "edit" else "playtest",
|
|
177
|
+
device = Emulation.deviceId(),
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
function Capture.register()
|
|
182
|
+
Dispatch.registerAll("capture", {
|
|
183
|
+
screenshot = Capture.screenshot,
|
|
184
|
+
})
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
return Capture
|