@el4cteo/rbx-studio-mcp 0.1.0 → 0.1.2
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 +48 -4
- package/dist/bridge/api.js +34 -0
- package/dist/bridge/api.js.map +1 -0
- package/dist/bridge/remote.js +122 -0
- package/dist/bridge/remote.js.map +1 -0
- package/dist/bridge/rpc.js +75 -31
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +107 -7
- package/dist/bridge/server.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/errors.js +48 -3
- package/dist/lib/errors.js.map +1 -1
- package/dist/tools/input.js +13 -1
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/playtest.js +3 -3
- package/dist/tools/playtest.js.map +1 -1
- package/dist/tools/screenshot.js +46 -11
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/session.js +14 -9
- package/dist/tools/session.js.map +1 -1
- package/package.json +62 -62
- package/plugin/src/Config.luau +59 -59
- package/plugin/src/handlers/Capture.luau +366 -187
- package/plugin/src/handlers/Debug.luau +2 -1
- package/plugin/src/handlers/Input.luau +166 -0
- package/scripts/prism.mjs +310 -0
- package/scripts/test-bridge.mjs +107 -0
|
@@ -1,187 +1,366 @@
|
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
local
|
|
23
|
-
local
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
--
|
|
27
|
-
|
|
28
|
-
local
|
|
29
|
-
local
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
--
|
|
33
|
-
--
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
--
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
function
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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 Players = game:GetService("Players")
|
|
20
|
+
local RunService = game:GetService("RunService")
|
|
21
|
+
|
|
22
|
+
local Dispatch = require(script.Parent.Parent.Dispatch)
|
|
23
|
+
local Emulation = require(script.Parent.Parent.Emulation)
|
|
24
|
+
local Png = require(script.Parent.Parent.Png)
|
|
25
|
+
|
|
26
|
+
-- Wide enough to read a GUI label, small enough that the encode stays quick and
|
|
27
|
+
-- the reply does not dominate the conversation it is part of.
|
|
28
|
+
local DEFAULT_WIDTH = 800
|
|
29
|
+
local MAX_WIDTH = 1600
|
|
30
|
+
local MIN_WIDTH = 160
|
|
31
|
+
|
|
32
|
+
-- The callback has never taken close to this. It exists so a capture that never
|
|
33
|
+
-- calls back fails with something an agent can act on rather than hanging the
|
|
34
|
+
-- session until the request deadline.
|
|
35
|
+
local CAPTURE_TIMEOUT = 10
|
|
36
|
+
|
|
37
|
+
-- Zstd level. 3 is its default and already gets most of the win on screen
|
|
38
|
+
-- content; the higher levels cost Studio's main thread for a few percent.
|
|
39
|
+
local COMPRESSION_LEVEL = 3
|
|
40
|
+
|
|
41
|
+
local Capture = {}
|
|
42
|
+
|
|
43
|
+
-- Only a content id comes back over the remote, so this is the time to start a
|
|
44
|
+
-- LocalScript and take one shot, not to move any pixels.
|
|
45
|
+
local CLIENT_TIMEOUT = 25
|
|
46
|
+
|
|
47
|
+
--[[
|
|
48
|
+
Screenshots during a playtest, taken by the client because only the client
|
|
49
|
+
may take them.
|
|
50
|
+
|
|
51
|
+
`CaptureService:CaptureScreenshot` refuses outright in the playtest's server
|
|
52
|
+
session -- "can only be called on the client" -- and the edit session, which
|
|
53
|
+
could call it, has stopped rendering its own data model and times out. Both
|
|
54
|
+
sessions this plugin can reach are therefore the wrong one, and for a long
|
|
55
|
+
time that was written down here as "screenshots are impossible mid-playtest".
|
|
56
|
+
|
|
57
|
+
They are not. The client is reachable the same way `input` reaches it: parent
|
|
58
|
+
a LocalScript into the player's PlayerGui and let it report back over a
|
|
59
|
+
RemoteEvent. The client cannot make HTTP requests, but it does not need to --
|
|
60
|
+
the server session it replicates to already holds an open bridge.
|
|
61
|
+
|
|
62
|
+
The client only takes the shot. It cannot read it: `CaptureService` hands
|
|
63
|
+
back a temporary texture id, and `CreateEditableImageAsync` refuses those at
|
|
64
|
+
script identity -- "cannot currently create editable image from temporary
|
|
65
|
+
texture id" -- which no amount of client-side code gets around.
|
|
66
|
+
|
|
67
|
+
The id is not client-local, though. It names a texture in the Studio
|
|
68
|
+
process, and the *editor* session's plugin runs there with plugin identity,
|
|
69
|
+
where the same call opens it without complaint. So the picture is taken in
|
|
70
|
+
one session and read in the other, and the pixels never cross a RemoteEvent
|
|
71
|
+
at all -- which also removes the size ceiling that a client-side encode had
|
|
72
|
+
to stay under. See `Capture.playtestId` and `Capture.decode`; `screenshot`
|
|
73
|
+
in the MCP server drives both halves.
|
|
74
|
+
]]
|
|
75
|
+
local CLIENT_SOURCE = [==[
|
|
76
|
+
local CaptureService = game:GetService("CaptureService")
|
|
77
|
+
|
|
78
|
+
local relay = script
|
|
79
|
+
local report = relay:WaitForChild("Report", 10)
|
|
80
|
+
if report == nil then
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
local contentId = nil
|
|
85
|
+
local okShot, shotErr = pcall(function()
|
|
86
|
+
CaptureService:CaptureScreenshot(function(id)
|
|
87
|
+
contentId = id
|
|
88
|
+
end)
|
|
89
|
+
end)
|
|
90
|
+
if not okShot then
|
|
91
|
+
report:FireServer({ ok = false, reason = tostring(shotErr) })
|
|
92
|
+
return
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
local waited = 0
|
|
96
|
+
while contentId == nil and waited < 12 do
|
|
97
|
+
task.wait(0.05)
|
|
98
|
+
waited += 0.05
|
|
99
|
+
end
|
|
100
|
+
if contentId == nil then
|
|
101
|
+
report:FireServer({ ok = false, reason = "the client did not produce a screenshot within 12s" })
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
report:FireServer({ ok = true, contentId = contentId })
|
|
106
|
+
]==]
|
|
107
|
+
|
|
108
|
+
--[[
|
|
109
|
+
Takes the shot and waits for the id.
|
|
110
|
+
|
|
111
|
+
`CaptureService:CaptureScreenshot` answers through a callback rather than
|
|
112
|
+
yielding, so this bridges the two: the handler is already running on its own
|
|
113
|
+
task and is free to wait.
|
|
114
|
+
]]
|
|
115
|
+
local function takeScreenshot(): string
|
|
116
|
+
local contentId: string? = nil
|
|
117
|
+
local failed: string? = nil
|
|
118
|
+
|
|
119
|
+
local ok, err = pcall(function()
|
|
120
|
+
CaptureService:CaptureScreenshot(function(id: string)
|
|
121
|
+
contentId = id
|
|
122
|
+
end)
|
|
123
|
+
end)
|
|
124
|
+
if not ok then
|
|
125
|
+
failed = tostring(err)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
if failed ~= nil then
|
|
129
|
+
Dispatch.fail(
|
|
130
|
+
"CAPTURE_REFUSED",
|
|
131
|
+
string.format("Studio refused to take a screenshot: %s", failed)
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
local waited = 0
|
|
136
|
+
while contentId == nil and waited < CAPTURE_TIMEOUT do
|
|
137
|
+
task.wait(0.05)
|
|
138
|
+
waited += 0.05
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if contentId == nil then
|
|
142
|
+
Dispatch.fail(
|
|
143
|
+
"CAPTURE_TIMEOUT",
|
|
144
|
+
string.format("Studio did not return a screenshot within %ds.", CAPTURE_TIMEOUT),
|
|
145
|
+
"The viewport may be hidden or Studio may be busy; try again."
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
return contentId :: any
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
--[[
|
|
153
|
+
Half a playtest screenshot: the client takes the shot, this returns its id.
|
|
154
|
+
|
|
155
|
+
Deliberately does not try to read it. The editor session does that, because
|
|
156
|
+
only plugin identity may -- see the note on CLIENT_SOURCE.
|
|
157
|
+
]]
|
|
158
|
+
function Capture.playtestId(_params: { [string]: any }): { [string]: any }
|
|
159
|
+
if RunService:IsEdit() or not RunService:IsRunning() then
|
|
160
|
+
Dispatch.fail(
|
|
161
|
+
"NOT_RUNNING",
|
|
162
|
+
"This is not a running playtest session, so there is no client to capture from.",
|
|
163
|
+
"Address this at the playtest's studioId from `list_studios`."
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
local players = Players:GetPlayers()
|
|
168
|
+
if #players == 0 then
|
|
169
|
+
Dispatch.fail(
|
|
170
|
+
"NO_PLAYER",
|
|
171
|
+
"No player is in this session, so there is no client to capture from.",
|
|
172
|
+
"Start a playtest with a character using `playtest op=\"play\"`."
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
local player = players[1]
|
|
176
|
+
local playerGui = player:FindFirstChildOfClass("PlayerGui")
|
|
177
|
+
if playerGui == nil then
|
|
178
|
+
Dispatch.fail("NO_PLAYER", string.format("%s has no PlayerGui yet.", player.Name))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
local remote = Instance.new("RemoteEvent")
|
|
182
|
+
remote.Name = "Report"
|
|
183
|
+
|
|
184
|
+
local relay = Instance.new("LocalScript")
|
|
185
|
+
relay.Name = "MCPCaptureRelay"
|
|
186
|
+
relay.Source = CLIENT_SOURCE
|
|
187
|
+
remote.Parent = relay
|
|
188
|
+
|
|
189
|
+
local answer: { [string]: any }? = nil
|
|
190
|
+
local connection = remote.OnServerEvent:Connect(function(_from, payload)
|
|
191
|
+
if typeof(payload) == "table" then
|
|
192
|
+
answer = payload :: { [string]: any }
|
|
193
|
+
end
|
|
194
|
+
end)
|
|
195
|
+
|
|
196
|
+
-- Parented last, for the same reason as the input relay: the script runs the
|
|
197
|
+
-- instant it lands and immediately waits on the RemoteEvent.
|
|
198
|
+
relay.Parent = playerGui
|
|
199
|
+
|
|
200
|
+
local deadline = os.clock() + CLIENT_TIMEOUT
|
|
201
|
+
while answer == nil and os.clock() < deadline do
|
|
202
|
+
task.wait(0.05)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
connection:Disconnect()
|
|
206
|
+
relay:Destroy()
|
|
207
|
+
|
|
208
|
+
if answer == nil then
|
|
209
|
+
Dispatch.fail(
|
|
210
|
+
"CAPTURE_TIMEOUT",
|
|
211
|
+
string.format("The client did not answer within %ds.", CLIENT_TIMEOUT),
|
|
212
|
+
"The client may still be loading; try again once the character has spawned."
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
local result = answer :: { [string]: any }
|
|
217
|
+
if result.ok ~= true then
|
|
218
|
+
Dispatch.fail("CAPTURE_REFUSED", string.format("The client could not capture: %s", tostring(result.reason)))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
return { contentId = tostring(result.contentId), device = Emulation.deviceId() }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
--[[
|
|
225
|
+
Turns a content id into pixels on the wire.
|
|
226
|
+
|
|
227
|
+
Split out from `screenshot` because a playtest shot is taken in one session
|
|
228
|
+
and read in another: the id is all that travels, and this is the reading
|
|
229
|
+
half, which needs plugin identity and therefore an editor session.
|
|
230
|
+
]]
|
|
231
|
+
local function encode(contentId: string, width: number, context: string): { [string]: any }
|
|
232
|
+
local okImage, image = pcall(function()
|
|
233
|
+
return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
|
|
234
|
+
end)
|
|
235
|
+
if not okImage then
|
|
236
|
+
Dispatch.fail(
|
|
237
|
+
"CAPTURE_UNREADABLE",
|
|
238
|
+
string.format("The screenshot could not be opened for reading: %s", tostring(image))
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
local size = (image :: any).Size
|
|
243
|
+
local sourceWidth = math.floor(size.X)
|
|
244
|
+
local sourceHeight = math.floor(size.Y)
|
|
245
|
+
|
|
246
|
+
--[[
|
|
247
|
+
Both arguments are required. Called with none it reports "expects 2
|
|
248
|
+
arguments" rather than defaulting to the whole image, which is worth
|
|
249
|
+
stating because every other read on this object takes none.
|
|
250
|
+
]]
|
|
251
|
+
local okPixels, pixels = pcall(function()
|
|
252
|
+
return (image :: any):ReadPixelsBuffer(Vector2.zero, size)
|
|
253
|
+
end)
|
|
254
|
+
if not okPixels then
|
|
255
|
+
Dispatch.fail(
|
|
256
|
+
"CAPTURE_UNREADABLE",
|
|
257
|
+
string.format("The screenshot's pixels could not be read: %s", tostring(pixels))
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
local rgb, outWidth, outHeight = Png.downscaleToRgb(pixels, sourceWidth, sourceHeight, width)
|
|
262
|
+
|
|
263
|
+
--[[
|
|
264
|
+
Raw pixels, compressed by the engine, assembled into a PNG by Node.
|
|
265
|
+
|
|
266
|
+
The plugin used to write the whole PNG itself, and could only write a bad
|
|
267
|
+
one: Studio has no deflate, so `Png.encode` emitted zlib *stored* blocks
|
|
268
|
+
-- the format's "compression not applied" escape hatch -- and every
|
|
269
|
+
screenshot travelled and landed at full uncompressed size. It was also
|
|
270
|
+
base64-ing by hand, a table lookup per byte in Luau.
|
|
271
|
+
|
|
272
|
+
`EncodingService` has both, natively. Zstd is the only algorithm the
|
|
273
|
+
engine exposes and PNG cannot use it, so the split is: the plugin
|
|
274
|
+
compresses the pixels for the wire, and Node -- which has real zlib --
|
|
275
|
+
decompresses and writes a properly deflated PNG. The side with the
|
|
276
|
+
compressor does the compressing.
|
|
277
|
+
|
|
278
|
+
Guarded rather than assumed: EncodingService is recent, and a Studio
|
|
279
|
+
without it should cost a bigger screenshot, not a failed one.
|
|
280
|
+
]]
|
|
281
|
+
local okPacked, packed = pcall(function()
|
|
282
|
+
local compressed = (EncodingService :: any):CompressBuffer(
|
|
283
|
+
rgb,
|
|
284
|
+
(Enum :: any).CompressionAlgorithm.Zstd,
|
|
285
|
+
COMPRESSION_LEVEL
|
|
286
|
+
)
|
|
287
|
+
return buffer.tostring((EncodingService :: any):Base64Encode(compressed))
|
|
288
|
+
end)
|
|
289
|
+
|
|
290
|
+
if okPacked then
|
|
291
|
+
return {
|
|
292
|
+
encoding = "zstd-rgb",
|
|
293
|
+
data = packed,
|
|
294
|
+
width = outWidth,
|
|
295
|
+
height = outHeight,
|
|
296
|
+
sourceWidth = sourceWidth,
|
|
297
|
+
sourceHeight = sourceHeight,
|
|
298
|
+
rawBytes = buffer.len(rgb),
|
|
299
|
+
bytes = #(packed :: string),
|
|
300
|
+
context = context,
|
|
301
|
+
device = Emulation.deviceId(),
|
|
302
|
+
}
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
local png = Png.encode(rgb, outWidth, outHeight)
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
encoding = "png",
|
|
309
|
+
data = Png.base64(png),
|
|
310
|
+
width = outWidth,
|
|
311
|
+
height = outHeight,
|
|
312
|
+
sourceWidth = sourceWidth,
|
|
313
|
+
sourceHeight = sourceHeight,
|
|
314
|
+
bytes = buffer.len(png),
|
|
315
|
+
context = context,
|
|
316
|
+
device = Emulation.deviceId(),
|
|
317
|
+
}
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
function Capture.screenshot(params: { [string]: any }): { [string]: any }
|
|
321
|
+
--[[
|
|
322
|
+
Refused rather than half-done. A playtest screenshot needs two sessions
|
|
323
|
+
and only the MCP server can see both, so failing here with the reason is
|
|
324
|
+
better than returning the editor's own idle viewport, which looks like a
|
|
325
|
+
valid answer to "what does the player see".
|
|
326
|
+
]]
|
|
327
|
+
if RunService:IsRunning() and not RunService:IsEdit() then
|
|
328
|
+
Dispatch.fail(
|
|
329
|
+
"NEEDS_EDITOR_SESSION",
|
|
330
|
+
"A playtest screenshot is taken on the client and read in the editor session, "
|
|
331
|
+
.. "so it cannot be served from the playtest server alone.",
|
|
332
|
+
"Use the `screenshot` tool, which drives both halves. It needs the editor "
|
|
333
|
+
.. "session for the same place to still be connected."
|
|
334
|
+
)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
local width = math.clamp(tonumber(params.width) or DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH)
|
|
338
|
+
return encode(takeScreenshot(), width, if RunService:IsEdit() then "edit" else "playtest")
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
--[[
|
|
342
|
+
Reads a content id someone else captured.
|
|
343
|
+
|
|
344
|
+
Only the editor session can do this, and only because the temporary texture
|
|
345
|
+
is owned by the Studio process rather than by the session that made it.
|
|
346
|
+
]]
|
|
347
|
+
function Capture.decode(params: { [string]: any }): { [string]: any }
|
|
348
|
+
local contentId = params.contentId
|
|
349
|
+
if typeof(contentId) ~= "string" or contentId == "" then
|
|
350
|
+
Dispatch.fail("BAD_PARAMS", "decode needs a `contentId`.")
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
local width = math.clamp(tonumber(params.width) or DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH)
|
|
354
|
+
local context = if typeof(params.context) == "string" then params.context else "playtest client"
|
|
355
|
+
return encode(contentId :: string, width, context)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
function Capture.register()
|
|
359
|
+
Dispatch.registerAll("capture", {
|
|
360
|
+
screenshot = Capture.screenshot,
|
|
361
|
+
playtestId = Capture.playtestId,
|
|
362
|
+
decode = Capture.decode,
|
|
363
|
+
})
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
return Capture
|
|
@@ -215,7 +215,8 @@ local function requireService()
|
|
|
215
215
|
"ScriptDebuggerService is not available in this session: %s",
|
|
216
216
|
tostring(installError)
|
|
217
217
|
),
|
|
218
|
-
"
|
|
218
|
+
"Turn on \"Debugger Luau API\" in File > Beta Features and restart Studio. "
|
|
219
|
+
.. "It is off by default, and the service is not registered until it is on."
|
|
219
220
|
)
|
|
220
221
|
end
|
|
221
222
|
end
|