@el4cteo/rbx-studio-mcp 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge/rpc.js +73 -0
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/tools/screenshot.js +19 -1
- package/dist/tools/screenshot.js.map +1 -1
- package/package.json +1 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +1101 -1041
- package/plugin/src/History.luau +187 -0
- package/plugin/src/Mirror.luau +20 -1
- package/plugin/src/handlers/Capture.luau +44 -0
- package/plugin/src/init.server.luau +636 -552
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
The console log, carried across the boundaries that used to erase it.
|
|
4
|
+
|
|
5
|
+
Pressing Play does not pause this plugin, it loads a second and a third copy
|
|
6
|
+
of it into the playtest's DataModels, each with its own empty console. Studio
|
|
7
|
+
then shows you the play view's panel, so the session you were watching
|
|
8
|
+
appears to have been wiped; stopping the test shows the editor's again, and
|
|
9
|
+
everything that happened during the test is missing from it instead. Nothing
|
|
10
|
+
crashed and nothing was lost -- the log was simply written in a window that is
|
|
11
|
+
no longer the one on screen.
|
|
12
|
+
|
|
13
|
+
Two halves fix that, and they are deliberately different mechanisms because
|
|
14
|
+
the two directions have different problems.
|
|
15
|
+
|
|
16
|
+
Forwards (editor -> playtest) is a cold start: the playtest's copies begin
|
|
17
|
+
with nothing and need what was already on screen. That is persistence, and it
|
|
18
|
+
is what this file does -- the edit session writes its rows to plugin
|
|
19
|
+
settings, and every copy reads them once at load.
|
|
20
|
+
|
|
21
|
+
Backwards (playtest -> editor) is not persistence at all. The editor session
|
|
22
|
+
is still running the whole time; it just is not the one being asked to do the
|
|
23
|
+
work. So there is nothing to restore, only something to be told, and it is
|
|
24
|
+
told over the bridge -- see `announceToPeers` in the server's rpc.ts.
|
|
25
|
+
|
|
26
|
+
Only the EDIT session writes. It is the one that outlives every playtest and
|
|
27
|
+
receives the peer announcements, so its log is the superset; letting the
|
|
28
|
+
playtest copies write as well would store the same rows twice and replay both
|
|
29
|
+
on the next load.
|
|
30
|
+
]]
|
|
31
|
+
|
|
32
|
+
local History = {}
|
|
33
|
+
|
|
34
|
+
local SETTING = "consoleHistory"
|
|
35
|
+
|
|
36
|
+
--[[
|
|
37
|
+
Rows kept, well under the console's own 300.
|
|
38
|
+
|
|
39
|
+
This is a settings file, rewritten in full on every save, and the value of an
|
|
40
|
+
old row falls off quickly -- what you want back is the screenful you were
|
|
41
|
+
looking at when the playtest started, not yesterday afternoon.
|
|
42
|
+
]]
|
|
43
|
+
local MAX_ROWS = 150
|
|
44
|
+
|
|
45
|
+
--[[
|
|
46
|
+
Seconds between saves.
|
|
47
|
+
|
|
48
|
+
A burst of commands writes several rows a second and every one of them would
|
|
49
|
+
otherwise be a rewrite of the whole file. The loss window is the same two
|
|
50
|
+
seconds, and the only way to lose that window is to close Studio inside it --
|
|
51
|
+
which `flush` covers anyway.
|
|
52
|
+
]]
|
|
53
|
+
local SAVE_EVERY = 2
|
|
54
|
+
|
|
55
|
+
export type Row = {
|
|
56
|
+
level: string,
|
|
57
|
+
message: string,
|
|
58
|
+
detail: string?,
|
|
59
|
+
stamp: string,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
local store: Plugin? = nil
|
|
63
|
+
local read: (() -> { Row })? = nil
|
|
64
|
+
local dirty = false
|
|
65
|
+
local running = false
|
|
66
|
+
|
|
67
|
+
--[[
|
|
68
|
+
Which place this log belongs to.
|
|
69
|
+
|
|
70
|
+
One settings file is shared by every Studio window on the machine, so without
|
|
71
|
+
a key a second place's session would read back the first's history and show
|
|
72
|
+
it as its own. An unsaved place has no id, so its name stands in -- imperfect
|
|
73
|
+
between two unsaved places, and better than merging them.
|
|
74
|
+
]]
|
|
75
|
+
local function placeKey(): string
|
|
76
|
+
if game.PlaceId ~= 0 then
|
|
77
|
+
return tostring(game.PlaceId)
|
|
78
|
+
end
|
|
79
|
+
return "local:" .. game.Name
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
--[[
|
|
83
|
+
Rows survive a round trip through JSON, so nothing that comes back is
|
|
84
|
+
trusted: a settings file is a file on disk, and a field that is missing or
|
|
85
|
+
the wrong type must skip the row rather than reach the renderer.
|
|
86
|
+
]]
|
|
87
|
+
local function sane(value: any): Row?
|
|
88
|
+
if typeof(value) ~= "table" then
|
|
89
|
+
return nil
|
|
90
|
+
end
|
|
91
|
+
local row = value :: { [string]: any }
|
|
92
|
+
if typeof(row.level) ~= "string" or typeof(row.message) ~= "string" then
|
|
93
|
+
return nil
|
|
94
|
+
end
|
|
95
|
+
if typeof(row.stamp) ~= "string" then
|
|
96
|
+
return nil
|
|
97
|
+
end
|
|
98
|
+
return {
|
|
99
|
+
level = row.level,
|
|
100
|
+
message = row.message,
|
|
101
|
+
detail = if typeof(row.detail) == "string" then row.detail else nil,
|
|
102
|
+
stamp = row.stamp,
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
--[[
|
|
107
|
+
`snapshot` is a function rather than rows, because saving is throttled: what
|
|
108
|
+
must be written is whatever the console holds when the timer fires, not what
|
|
109
|
+
it held when the row that triggered it was logged.
|
|
110
|
+
]]
|
|
111
|
+
function History.attach(pluginObject: Plugin, snapshot: () -> { Row })
|
|
112
|
+
store = pluginObject
|
|
113
|
+
read = snapshot
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
function History.restore(): { Row }
|
|
117
|
+
local pluginObject = store
|
|
118
|
+
if pluginObject == nil then
|
|
119
|
+
return {}
|
|
120
|
+
end
|
|
121
|
+
local saved = pluginObject:GetSetting(SETTING)
|
|
122
|
+
if typeof(saved) ~= "table" then
|
|
123
|
+
return {}
|
|
124
|
+
end
|
|
125
|
+
local record = saved :: { [string]: any }
|
|
126
|
+
if record.place ~= placeKey() or typeof(record.rows) ~= "table" then
|
|
127
|
+
return {}
|
|
128
|
+
end
|
|
129
|
+
local rows: { Row } = {}
|
|
130
|
+
for _, entry in record.rows :: { any } do
|
|
131
|
+
local row = sane(entry)
|
|
132
|
+
if row ~= nil then
|
|
133
|
+
table.insert(rows, row)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
return rows
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
--[[
|
|
140
|
+
Writes now. Called from the save loop and from `plugin.Unloading`, where
|
|
141
|
+
there is no next tick to wait for.
|
|
142
|
+
]]
|
|
143
|
+
function History.flush()
|
|
144
|
+
local pluginObject, snapshot = store, read
|
|
145
|
+
if pluginObject == nil or snapshot == nil then
|
|
146
|
+
return
|
|
147
|
+
end
|
|
148
|
+
dirty = false
|
|
149
|
+
local rows = snapshot()
|
|
150
|
+
while #rows > MAX_ROWS do
|
|
151
|
+
table.remove(rows, 1)
|
|
152
|
+
end
|
|
153
|
+
-- Guarded because a settings write is disk I/O: a full disk or a locked file
|
|
154
|
+
-- must cost the console its history, not its session.
|
|
155
|
+
pcall(function()
|
|
156
|
+
pluginObject:SetSetting(SETTING, { place = placeKey(), rows = rows })
|
|
157
|
+
end)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
-- Marks the log as changed. Cheap on purpose: it runs on every logged row.
|
|
161
|
+
function History.touch()
|
|
162
|
+
dirty = true
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
--[[
|
|
166
|
+
Starts the save loop. Owned here rather than by the caller so the throttle
|
|
167
|
+
and the thing it throttles cannot drift apart.
|
|
168
|
+
]]
|
|
169
|
+
function History.start()
|
|
170
|
+
running = true
|
|
171
|
+
task.spawn(function()
|
|
172
|
+
while running do
|
|
173
|
+
task.wait(SAVE_EVERY)
|
|
174
|
+
if running and dirty then
|
|
175
|
+
History.flush()
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
-- Stops the loop on unload. A reloaded plugin is a fresh module, so without
|
|
182
|
+
-- this every reload would leave another timer running against a dead session.
|
|
183
|
+
function History.stop()
|
|
184
|
+
running = false
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
return History
|
package/plugin/src/Mirror.luau
CHANGED
|
@@ -40,6 +40,25 @@ local Mirror = {}
|
|
|
40
40
|
|
|
41
41
|
local remote: RemoteEvent? = nil
|
|
42
42
|
|
|
43
|
+
--[[
|
|
44
|
+
Held shut until the server session has finished introducing itself.
|
|
45
|
+
|
|
46
|
+
Both halves of a playtest log their own banner, place and status, so a
|
|
47
|
+
channel that is open from the start delivers the server's copy of all of it
|
|
48
|
+
to a client that has already written its own -- the panel comes up saying
|
|
49
|
+
"rbx-studio v0.3.6" and "place: Game" twice, a line apart, which reads as a
|
|
50
|
+
glitch because it is one.
|
|
51
|
+
|
|
52
|
+
Opened on the server's "connected" row rather than after a fixed wait: that
|
|
53
|
+
is the exact point where its startup ends and the session's real activity
|
|
54
|
+
begins, and everything after it is news the client does not already have.
|
|
55
|
+
]]
|
|
56
|
+
local open = false
|
|
57
|
+
|
|
58
|
+
function Mirror.open()
|
|
59
|
+
open = true
|
|
60
|
+
end
|
|
61
|
+
|
|
43
62
|
--[[
|
|
44
63
|
True only in the half of a playtest that can reach the bridge.
|
|
45
64
|
|
|
@@ -88,7 +107,7 @@ end
|
|
|
88
107
|
]]
|
|
89
108
|
function Mirror.send(kind: string, arguments: { any })
|
|
90
109
|
local event = remote
|
|
91
|
-
if event == nil then
|
|
110
|
+
if event == nil or not open then
|
|
92
111
|
return
|
|
93
112
|
end
|
|
94
113
|
pcall(function()
|
|
@@ -40,6 +40,37 @@ local COMPRESSION_LEVEL = 3
|
|
|
40
40
|
|
|
41
41
|
local Capture = {}
|
|
42
42
|
|
|
43
|
+
--[[
|
|
44
|
+
Channel value at or under which a byte counts as black.
|
|
45
|
+
|
|
46
|
+
Not zero. A capture that failed comes back at exactly zero, but so does a
|
|
47
|
+
frame that picked up a hair of dithering, and being off by one is not worth
|
|
48
|
+
an argument.
|
|
49
|
+
]]
|
|
50
|
+
local BLACK_LEVEL = 8
|
|
51
|
+
|
|
52
|
+
--[[
|
|
53
|
+
Whether every pixel in the image is black.
|
|
54
|
+
|
|
55
|
+
A black screenshot is the one failure this tool cannot report by failing: the
|
|
56
|
+
call succeeds, the PNG is valid, and the agent reads it as "the game is dark"
|
|
57
|
+
and reasons on from there. So it is measured and said out loud instead.
|
|
58
|
+
|
|
59
|
+
Written to leave on the first byte that is not black, which is what makes it
|
|
60
|
+
free. A real frame -- even a night scene, which still has a skybox gradient,
|
|
61
|
+
a GUI or the topbar somewhere -- exits within the first handful of pixels.
|
|
62
|
+
The whole buffer is only ever walked when the answer is yes, and at that
|
|
63
|
+
point the walk has earned itself.
|
|
64
|
+
]]
|
|
65
|
+
local function isBlack(rgb: buffer): boolean
|
|
66
|
+
for offset = 0, buffer.len(rgb) - 1 do
|
|
67
|
+
if buffer.readu8(rgb, offset) > BLACK_LEVEL then
|
|
68
|
+
return false
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
return true
|
|
72
|
+
end
|
|
73
|
+
|
|
43
74
|
-- Only a content id comes back over the remote, so this is the time to start a
|
|
44
75
|
-- LocalScript and take one shot, not to move any pixels.
|
|
45
76
|
local CLIENT_TIMEOUT = 25
|
|
@@ -74,6 +105,7 @@ local CLIENT_TIMEOUT = 25
|
|
|
74
105
|
]]
|
|
75
106
|
local CLIENT_SOURCE = [==[
|
|
76
107
|
local CaptureService = game:GetService("CaptureService")
|
|
108
|
+
local RunService = game:GetService("RunService")
|
|
77
109
|
|
|
78
110
|
local relay = script
|
|
79
111
|
local report = relay:WaitForChild("Report", 10)
|
|
@@ -81,6 +113,15 @@ if report == nil then
|
|
|
81
113
|
return
|
|
82
114
|
end
|
|
83
115
|
|
|
116
|
+
-- One rendered frame before the shot. The RemoteEvent is already a child of
|
|
117
|
+
-- this script when it lands, so `WaitForChild` returns at once and the capture
|
|
118
|
+
-- would otherwise happen on the frame the relay arrived -- which is the frame
|
|
119
|
+
-- least likely to have finished drawing. Roblox's own working repro for
|
|
120
|
+
-- CaptureService waits on PreRender first, and it costs a sixtieth of a second.
|
|
121
|
+
pcall(function()
|
|
122
|
+
RunService.PreRender:Wait()
|
|
123
|
+
end)
|
|
124
|
+
|
|
84
125
|
local contentId = nil
|
|
85
126
|
local okShot, shotErr = pcall(function()
|
|
86
127
|
CaptureService:CaptureScreenshot(function(id)
|
|
@@ -259,6 +300,7 @@ local function encode(contentId: string, width: number, context: string): { [str
|
|
|
259
300
|
end
|
|
260
301
|
|
|
261
302
|
local rgb, outWidth, outHeight = Png.downscaleToRgb(pixels, sourceWidth, sourceHeight, width)
|
|
303
|
+
local black = isBlack(rgb)
|
|
262
304
|
|
|
263
305
|
--[[
|
|
264
306
|
Raw pixels, compressed by the engine, assembled into a PNG by Node.
|
|
@@ -298,6 +340,7 @@ local function encode(contentId: string, width: number, context: string): { [str
|
|
|
298
340
|
rawBytes = buffer.len(rgb),
|
|
299
341
|
bytes = #(packed :: string),
|
|
300
342
|
context = context,
|
|
343
|
+
black = black,
|
|
301
344
|
device = Emulation.deviceId(),
|
|
302
345
|
}
|
|
303
346
|
end
|
|
@@ -313,6 +356,7 @@ local function encode(contentId: string, width: number, context: string): { [str
|
|
|
313
356
|
sourceHeight = sourceHeight,
|
|
314
357
|
bytes = buffer.len(png),
|
|
315
358
|
context = context,
|
|
359
|
+
black = black,
|
|
316
360
|
device = Emulation.deviceId(),
|
|
317
361
|
}
|
|
318
362
|
end
|