@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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/bridge/rpc.js +243 -0
  4. package/dist/bridge/rpc.js.map +1 -0
  5. package/dist/bridge/server.js +281 -0
  6. package/dist/bridge/server.js.map +1 -0
  7. package/dist/index.js +104 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/apidump.js +269 -0
  10. package/dist/lib/apidump.js.map +1 -0
  11. package/dist/lib/errors.js +38 -0
  12. package/dist/lib/errors.js.map +1 -0
  13. package/dist/lib/format.js +191 -0
  14. package/dist/lib/format.js.map +1 -0
  15. package/dist/lib/pluginbuild.js +83 -0
  16. package/dist/lib/pluginbuild.js.map +1 -0
  17. package/dist/lib/png.js +84 -0
  18. package/dist/lib/png.js.map +1 -0
  19. package/dist/lib/protocol.js +22 -0
  20. package/dist/lib/protocol.js.map +1 -0
  21. package/dist/lib/tool.js +27 -0
  22. package/dist/lib/tool.js.map +1 -0
  23. package/dist/resources.js +70 -0
  24. package/dist/resources.js.map +1 -0
  25. package/dist/tools/api.js +78 -0
  26. package/dist/tools/api.js.map +1 -0
  27. package/dist/tools/character.js +94 -0
  28. package/dist/tools/character.js.map +1 -0
  29. package/dist/tools/debug.js +211 -0
  30. package/dist/tools/debug.js.map +1 -0
  31. package/dist/tools/device.js +74 -0
  32. package/dist/tools/device.js.map +1 -0
  33. package/dist/tools/discover.js +217 -0
  34. package/dist/tools/discover.js.map +1 -0
  35. package/dist/tools/exec.js +191 -0
  36. package/dist/tools/exec.js.map +1 -0
  37. package/dist/tools/input.js +96 -0
  38. package/dist/tools/input.js.map +1 -0
  39. package/dist/tools/instances.js +261 -0
  40. package/dist/tools/instances.js.map +1 -0
  41. package/dist/tools/perf.js +367 -0
  42. package/dist/tools/perf.js.map +1 -0
  43. package/dist/tools/playtest.js +153 -0
  44. package/dist/tools/playtest.js.map +1 -0
  45. package/dist/tools/screenshot.js +75 -0
  46. package/dist/tools/screenshot.js.map +1 -0
  47. package/dist/tools/scripts.js +316 -0
  48. package/dist/tools/scripts.js.map +1 -0
  49. package/dist/tools/session.js +152 -0
  50. package/dist/tools/session.js.map +1 -0
  51. package/dist/tools/world.js +281 -0
  52. package/dist/tools/world.js.map +1 -0
  53. package/package.json +62 -0
  54. package/plugin/default.project.json +6 -0
  55. package/plugin/src/Config.luau +59 -0
  56. package/plugin/src/Console.luau +657 -0
  57. package/plugin/src/Context.luau +35 -0
  58. package/plugin/src/Dispatch.luau +90 -0
  59. package/plugin/src/Editor.luau +142 -0
  60. package/plugin/src/Emulation.luau +151 -0
  61. package/plugin/src/LogBuffer.luau +277 -0
  62. package/plugin/src/Net.luau +102 -0
  63. package/plugin/src/Paths.luau +255 -0
  64. package/plugin/src/Phrase.luau +465 -0
  65. package/plugin/src/Png.luau +238 -0
  66. package/plugin/src/Scope.luau +78 -0
  67. package/plugin/src/ScriptEdit.luau +100 -0
  68. package/plugin/src/Serialize.luau +287 -0
  69. package/plugin/src/TextEdit.luau +296 -0
  70. package/plugin/src/Transport.luau +328 -0
  71. package/plugin/src/Undo.luau +72 -0
  72. package/plugin/src/Visuals.luau +710 -0
  73. package/plugin/src/handlers/Api.luau +242 -0
  74. package/plugin/src/handlers/Assets.luau +145 -0
  75. package/plugin/src/handlers/Capture.luau +187 -0
  76. package/plugin/src/handlers/Character.luau +361 -0
  77. package/plugin/src/handlers/Debug.luau +391 -0
  78. package/plugin/src/handlers/Device.luau +119 -0
  79. package/plugin/src/handlers/Discover.luau +289 -0
  80. package/plugin/src/handlers/Exec.luau +270 -0
  81. package/plugin/src/handlers/Geometry.luau +261 -0
  82. package/plugin/src/handlers/Input.luau +287 -0
  83. package/plugin/src/handlers/Instances.luau +389 -0
  84. package/plugin/src/handlers/Perf.luau +645 -0
  85. package/plugin/src/handlers/Playtest.luau +205 -0
  86. package/plugin/src/handlers/Scripts.luau +387 -0
  87. package/plugin/src/handlers/Session.luau +168 -0
  88. package/plugin/src/handlers/Viewport.luau +302 -0
  89. package/plugin/src/handlers/World.luau +176 -0
  90. package/plugin/src/init.server.luau +317 -0
  91. package/scripts/build-plugin.mjs +157 -0
  92. package/scripts/check-plugin.mjs +97 -0
  93. package/scripts/install-plugin.mjs +39 -0
  94. package/scripts/latency.mjs +201 -0
  95. package/scripts/locate-luau.mjs +51 -0
  96. package/scripts/sourcemap.mjs +58 -0
  97. 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