@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,90 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Handler registry and the single error boundary around every command.
|
|
4
|
+
|
|
5
|
+
Handlers raise failures with `Dispatch.fail(code, message, hint)`. The hint is
|
|
6
|
+
forwarded verbatim to the agent, so it should say what to do next ("call find
|
|
7
|
+
first to get a valid path"), not just what went wrong.
|
|
8
|
+
]]
|
|
9
|
+
|
|
10
|
+
local Dispatch = {}
|
|
11
|
+
|
|
12
|
+
export type Error = {
|
|
13
|
+
code: string,
|
|
14
|
+
message: string,
|
|
15
|
+
hint: string?,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Handler = (params: { [string]: any }) -> any
|
|
19
|
+
|
|
20
|
+
local handlers: { [string]: Handler } = {}
|
|
21
|
+
|
|
22
|
+
function Dispatch.register(op: string, handler: Handler)
|
|
23
|
+
handlers[op] = handler
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
--[[
|
|
27
|
+
Registers every entry of `map` under the prefix, e.g. `registerAll("script",
|
|
28
|
+
{ read = fn })` exposes "script.read".
|
|
29
|
+
]]
|
|
30
|
+
function Dispatch.registerAll(prefix: string, map: { [string]: Handler })
|
|
31
|
+
for name, handler in map do
|
|
32
|
+
handlers[prefix .. "." .. name] = handler
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
--[[
|
|
37
|
+
Raises a structured failure. Always call this rather than `error("...")` so
|
|
38
|
+
the agent receives a code it can branch on and a hint it can act on.
|
|
39
|
+
]]
|
|
40
|
+
function Dispatch.fail(code: string, message: string, hint: string?): never
|
|
41
|
+
error({ code = code, message = message, hint = hint }, 0)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
export type Result = {
|
|
45
|
+
id: string,
|
|
46
|
+
ok: boolean,
|
|
47
|
+
data: any?,
|
|
48
|
+
error: Error?,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
--[[
|
|
52
|
+
Runs one command and returns the frame to POST back. Never throws: an
|
|
53
|
+
unhandled error inside a handler becomes a HANDLER_ERROR result, because a
|
|
54
|
+
crashed dispatch would leave the server's promise hanging until it times out.
|
|
55
|
+
]]
|
|
56
|
+
function Dispatch.invoke(id: string, op: string, params: { [string]: any }?): Result
|
|
57
|
+
local handler = handlers[op]
|
|
58
|
+
if not handler then
|
|
59
|
+
return {
|
|
60
|
+
id = id,
|
|
61
|
+
ok = false,
|
|
62
|
+
error = {
|
|
63
|
+
code = "UNKNOWN_OP",
|
|
64
|
+
message = string.format('This Studio plugin has no handler for "%s".', op),
|
|
65
|
+
hint = "The plugin is older than the MCP server. Update the Studio MCP "
|
|
66
|
+
.. "plugin to the version matching your server.",
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
local ok, result = pcall(handler, params or {})
|
|
72
|
+
if ok then
|
|
73
|
+
return { id = id, ok = true, data = result }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if typeof(result) == "table" and (result :: any).code then
|
|
77
|
+
return { id = id, ok = false, error = result :: Error }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
id = id,
|
|
82
|
+
ok = false,
|
|
83
|
+
error = {
|
|
84
|
+
code = "HANDLER_ERROR",
|
|
85
|
+
message = string.format('"%s" failed: %s', op, tostring(result)),
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
return Dispatch
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
What the user is currently looking at in the script editor.
|
|
4
|
+
|
|
5
|
+
`ScriptEditorService` is normally used only to read and write source. The
|
|
6
|
+
document layer underneath it -- open tabs, cursor position, selected text,
|
|
7
|
+
which lines are on screen -- is left untouched by every other Studio MCP
|
|
8
|
+
server, and it answers the question an agent most often has to guess at: when
|
|
9
|
+
the user says "fix this function", which one do they mean.
|
|
10
|
+
|
|
11
|
+
Everything here is read-only and defensive. A ScriptDocument can close between
|
|
12
|
+
being listed and being read, and the accessors throw rather than return nil
|
|
13
|
+
when that happens, so each one is wrapped.
|
|
14
|
+
]]
|
|
15
|
+
|
|
16
|
+
local ScriptEditorService = game:GetService("ScriptEditorService")
|
|
17
|
+
|
|
18
|
+
local Paths = require(script.Parent.Paths)
|
|
19
|
+
|
|
20
|
+
-- Selected text is included so the agent can act on it without a second call,
|
|
21
|
+
-- but a user can select an entire file. Past this it is summarised instead.
|
|
22
|
+
local MAX_SELECTED_TEXT = 400
|
|
23
|
+
|
|
24
|
+
local Editor = {}
|
|
25
|
+
|
|
26
|
+
export type Document = { [string]: any }
|
|
27
|
+
|
|
28
|
+
--[[
|
|
29
|
+
Reads an accessor, yielding nil rather than raising when the document closed
|
|
30
|
+
underneath us. Values come back boxed in a table so a multi-value accessor
|
|
31
|
+
survives the pcall boundary intact.
|
|
32
|
+
]]
|
|
33
|
+
local function safe(accessor: () -> any): any
|
|
34
|
+
local ok, value = pcall(accessor)
|
|
35
|
+
if ok then
|
|
36
|
+
return value
|
|
37
|
+
end
|
|
38
|
+
return nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
local function describe(document: ScriptDocument): Document?
|
|
42
|
+
-- The command bar is a ScriptDocument too, and it is always open. Reporting
|
|
43
|
+
-- it would put a line of noise in every studio_status -- the call agents make
|
|
44
|
+
-- most -- for something that has no backing script and cannot be edited.
|
|
45
|
+
local isCommandBar = safe(function()
|
|
46
|
+
return document:IsCommandBar()
|
|
47
|
+
end)
|
|
48
|
+
if isCommandBar ~= false then
|
|
49
|
+
return nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
local found = safe(function()
|
|
53
|
+
return document:GetScript()
|
|
54
|
+
end)
|
|
55
|
+
if typeof(found) ~= "Instance" then
|
|
56
|
+
return nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
local target = found :: Instance
|
|
60
|
+
local entry: Document = {
|
|
61
|
+
path = Paths.of(target),
|
|
62
|
+
className = target.ClassName,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
entry.lineCount = safe(function()
|
|
66
|
+
return document:GetLineCount()
|
|
67
|
+
end)
|
|
68
|
+
|
|
69
|
+
-- GetSelection returns cursor line/character followed by the anchor: the
|
|
70
|
+
-- cursor is where the caret is, the anchor where the selection started, and
|
|
71
|
+
-- they are equal when nothing is selected.
|
|
72
|
+
local raw = safe(function()
|
|
73
|
+
local line, character, anchorLine, anchorCharacter = document:GetSelection()
|
|
74
|
+
return { line, character, anchorLine, anchorCharacter }
|
|
75
|
+
end)
|
|
76
|
+
if typeof(raw) == "table" then
|
|
77
|
+
local selection = raw :: { number }
|
|
78
|
+
entry.cursorLine = selection[1]
|
|
79
|
+
entry.cursorColumn = selection[2]
|
|
80
|
+
|
|
81
|
+
local hasSelection = selection[1] ~= selection[3] or selection[2] ~= selection[4]
|
|
82
|
+
if hasSelection then
|
|
83
|
+
-- Normalised, because a selection dragged upwards reports its anchor
|
|
84
|
+
-- below its cursor and a raw start/end pair would read as inverted.
|
|
85
|
+
local startLine = math.min(selection[1], selection[3])
|
|
86
|
+
local endLine = math.max(selection[1], selection[3])
|
|
87
|
+
entry.selectedLines = if startLine == endLine
|
|
88
|
+
then tostring(startLine)
|
|
89
|
+
else string.format("%d-%d", startLine, endLine)
|
|
90
|
+
|
|
91
|
+
local selected = safe(function()
|
|
92
|
+
return document:GetSelectedText()
|
|
93
|
+
end)
|
|
94
|
+
if typeof(selected) == "string" then
|
|
95
|
+
entry.selectedText = if #selected > MAX_SELECTED_TEXT
|
|
96
|
+
then string.sub(selected, 1, MAX_SELECTED_TEXT) .. "..."
|
|
97
|
+
else selected
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
local viewport = safe(function()
|
|
103
|
+
local first, last = document:GetViewport()
|
|
104
|
+
return { first, last }
|
|
105
|
+
end)
|
|
106
|
+
if typeof(viewport) == "table" then
|
|
107
|
+
local bounds = viewport :: { number }
|
|
108
|
+
if bounds[1] and bounds[2] then
|
|
109
|
+
entry.visibleLines = string.format("%d-%d", bounds[1], bounds[2])
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
return entry
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
--[[
|
|
117
|
+
Every script the user has open, most recently interesting first. Studio gives
|
|
118
|
+
no "focused document" API, so the caller is told what is open rather than
|
|
119
|
+
which tab has focus -- claiming focus we cannot observe would be worse than
|
|
120
|
+
saying nothing.
|
|
121
|
+
]]
|
|
122
|
+
function Editor.documents(): { Document }
|
|
123
|
+
local ok, documents = pcall(function()
|
|
124
|
+
return ScriptEditorService:GetScriptDocuments()
|
|
125
|
+
end)
|
|
126
|
+
if not ok or typeof(documents) ~= "table" then
|
|
127
|
+
return {}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
local entries: { Document } = {}
|
|
131
|
+
for _, document in documents :: { Instance } do
|
|
132
|
+
if document:IsA("ScriptDocument") then
|
|
133
|
+
local entry = describe(document)
|
|
134
|
+
if entry then
|
|
135
|
+
table.insert(entries, entry)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
return entries
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
return Editor
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
What device, if any, Studio's viewport is currently pretending to be.
|
|
4
|
+
|
|
5
|
+
Three places need this and none of them can afford to disagree: the `device`
|
|
6
|
+
tool reports it, `screenshot` captions it, and `studio_status` mentions it so
|
|
7
|
+
an agent that never set a device still learns why the viewport is a strange
|
|
8
|
+
shape. Emulation persists until it is switched off, which is exactly the kind
|
|
9
|
+
of state that goes wrong quietly.
|
|
10
|
+
|
|
11
|
+
`StudioDeviceSimulatorService` is undocumented on its return shapes, so what
|
|
12
|
+
is here was read off a live session. Two things are worth knowing:
|
|
13
|
+
|
|
14
|
+
* Every read THROWS while no device is active -- "no device is active, call
|
|
15
|
+
SetDeviceAsync() first" -- rather than returning a default, so each one is
|
|
16
|
+
guarded separately and costs only its own field.
|
|
17
|
+
* `GetResolutionAsync` reports the panel's native size, which does not turn
|
|
18
|
+
when the device does. An iPhone 16 emulated in portrait still answers
|
|
19
|
+
852x393. See `orientedSize`.
|
|
20
|
+
]]
|
|
21
|
+
|
|
22
|
+
local StudioDeviceSimulatorService = game:GetService("StudioDeviceSimulatorService")
|
|
23
|
+
|
|
24
|
+
local Emulation = {}
|
|
25
|
+
|
|
26
|
+
--[[
|
|
27
|
+
The resolution as it actually appears, rather than as the panel is specified.
|
|
28
|
+
|
|
29
|
+
Reporting 852x393 for a screen that is 393 wide and 852 tall is worse than
|
|
30
|
+
reporting nothing: getting the shape of the screen right is the entire reason
|
|
31
|
+
device emulation exists, and a caller sizing a menu against those numbers
|
|
32
|
+
would place it off the bottom of the display it was told it fits.
|
|
33
|
+
|
|
34
|
+
Only swapped when the orientation names a direction. "Sensor" means the
|
|
35
|
+
device decides, and guessing on its behalf would trade a knowable error for
|
|
36
|
+
an unknowable one.
|
|
37
|
+
]]
|
|
38
|
+
local function orientedSize(size: Vector2, orientation: string?): (number, number)
|
|
39
|
+
local width = math.floor(size.X)
|
|
40
|
+
local height = math.floor(size.Y)
|
|
41
|
+
|
|
42
|
+
local wantsPortrait: boolean? = nil
|
|
43
|
+
if orientation ~= nil then
|
|
44
|
+
if string.find(orientation, "Portrait") ~= nil then
|
|
45
|
+
wantsPortrait = true
|
|
46
|
+
elseif string.find(orientation, "Landscape") ~= nil then
|
|
47
|
+
wantsPortrait = false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
-- `width > height` is the shape we have; `wantsPortrait` is the shape we
|
|
52
|
+
-- want. Equal means they disagree, because portrait is the narrow one.
|
|
53
|
+
if wantsPortrait ~= nil and wantsPortrait == (width > height) then
|
|
54
|
+
return height, width
|
|
55
|
+
end
|
|
56
|
+
return width, height
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
--[[
|
|
60
|
+
The active device id, or nil when the viewport is the ordinary editor.
|
|
61
|
+
|
|
62
|
+
"default" is the simulator's own word for not emulating; it is turned into
|
|
63
|
+
nil here so callers test one thing rather than two.
|
|
64
|
+
]]
|
|
65
|
+
function Emulation.deviceId(): string?
|
|
66
|
+
local ok, id = pcall(function()
|
|
67
|
+
return (StudioDeviceSimulatorService :: any):GetDeviceAsync()
|
|
68
|
+
end)
|
|
69
|
+
if not ok or id == nil or tostring(id) == "default" then
|
|
70
|
+
return nil
|
|
71
|
+
end
|
|
72
|
+
return tostring(id)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
--[[
|
|
76
|
+
Orientation with the enum prefix removed: "Portrait", "LandscapeRight".
|
|
77
|
+
]]
|
|
78
|
+
function Emulation.orientation(): string?
|
|
79
|
+
local ok, orientation = pcall(function()
|
|
80
|
+
return (StudioDeviceSimulatorService :: any):GetOrientationAsync()
|
|
81
|
+
end)
|
|
82
|
+
if not ok then
|
|
83
|
+
return nil
|
|
84
|
+
end
|
|
85
|
+
local name = tostring(orientation):gsub("Enum%.ScreenOrientation%.", "")
|
|
86
|
+
return name
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
--[[
|
|
90
|
+
"393x852", turned the way the screen is actually turned.
|
|
91
|
+
]]
|
|
92
|
+
function Emulation.resolution(orientation: string?): string?
|
|
93
|
+
local ok, size = pcall(function()
|
|
94
|
+
return (StudioDeviceSimulatorService :: any):GetResolutionAsync()
|
|
95
|
+
end)
|
|
96
|
+
if not ok or typeof(size) ~= "Vector2" then
|
|
97
|
+
return nil
|
|
98
|
+
end
|
|
99
|
+
local width, height = orientedSize(size :: Vector2, orientation)
|
|
100
|
+
return string.format("%dx%d", width, height)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
--[[
|
|
104
|
+
Everything the simulator will say, for the `device` tool.
|
|
105
|
+
]]
|
|
106
|
+
function Emulation.state(): { [string]: any }
|
|
107
|
+
local id = Emulation.deviceId()
|
|
108
|
+
local orientation = Emulation.orientation()
|
|
109
|
+
|
|
110
|
+
local state: { [string]: any } = {
|
|
111
|
+
device = id or "default",
|
|
112
|
+
emulating = id ~= nil,
|
|
113
|
+
}
|
|
114
|
+
state.resolution = Emulation.resolution(orientation)
|
|
115
|
+
state.orientation = orientation
|
|
116
|
+
|
|
117
|
+
local okDensity, density = pcall(function()
|
|
118
|
+
return (StudioDeviceSimulatorService :: any):GetPixelDensityAsync()
|
|
119
|
+
end)
|
|
120
|
+
if okDensity then
|
|
121
|
+
state.pixelDensity = math.round((tonumber(density) :: number) * 100) / 100
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
local okScaling, scaling = pcall(function()
|
|
125
|
+
return (StudioDeviceSimulatorService :: any):GetScalingModeAsync()
|
|
126
|
+
end)
|
|
127
|
+
if okScaling then
|
|
128
|
+
state.scalingMode = tostring(scaling)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
return state
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
--[[
|
|
135
|
+
The short form carried by `studio_status`, or nil when nothing is emulated.
|
|
136
|
+
]]
|
|
137
|
+
function Emulation.summary(): { [string]: any }?
|
|
138
|
+
local id = Emulation.deviceId()
|
|
139
|
+
if id == nil then
|
|
140
|
+
return nil
|
|
141
|
+
end
|
|
142
|
+
local orientation = Emulation.orientation()
|
|
143
|
+
return {
|
|
144
|
+
id = id,
|
|
145
|
+
resolution = Emulation.resolution(orientation),
|
|
146
|
+
orientation = orientation,
|
|
147
|
+
note = 'The viewport is emulating this device. `device op="stop"` returns it to normal.',
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
return Emulation
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
The output log, kept ourselves.
|
|
4
|
+
|
|
5
|
+
`LogService:GetLogHistory()` looks like the right API and cannot be relied on.
|
|
6
|
+
Measured: in an editor session it returns a handful of lines from around
|
|
7
|
+
startup and then stops growing -- prints made later never appear -- and in a
|
|
8
|
+
playtest server's DataModel it succeeds and returns nothing at all, even
|
|
9
|
+
immediately after something printed there. The call reports no error either
|
|
10
|
+
way, so the tool built on it answered "the log is empty" for a place that was
|
|
11
|
+
logging steadily, which is worse than answering nothing.
|
|
12
|
+
|
|
13
|
+
`LogService.MessageOut` does fire reliably in both. So the buffer is filled
|
|
14
|
+
from the event, seeded once from whatever history the API does offer, and kept
|
|
15
|
+
from growing without bound.
|
|
16
|
+
|
|
17
|
+
This has to be connected at plugin load rather than when a read arrives:
|
|
18
|
+
anything logged before the first subscription is gone for good.
|
|
19
|
+
]]
|
|
20
|
+
|
|
21
|
+
local LogService = game:GetService("LogService")
|
|
22
|
+
local ScriptContext = game:GetService("ScriptContext")
|
|
23
|
+
|
|
24
|
+
-- Roughly what the Output window keeps. Big enough that a playtest's worth of
|
|
25
|
+
-- logging survives, small enough to stay cheap to hold and to scan.
|
|
26
|
+
local CAPACITY = 2000
|
|
27
|
+
|
|
28
|
+
-- Trimming in batches rather than one entry per insert: a chunk that logs in a
|
|
29
|
+
-- loop would otherwise shift the whole array on every single line.
|
|
30
|
+
local SLACK = 500
|
|
31
|
+
|
|
32
|
+
-- How far back an arriving stack trace will look for the error line it belongs
|
|
33
|
+
-- to. The two events fire within moments of each other, so anything beyond a
|
|
34
|
+
-- handful of entries is a mismatch rather than a late delivery.
|
|
35
|
+
local CORRELATION_WINDOW = 20
|
|
36
|
+
|
|
37
|
+
export type Entry = {
|
|
38
|
+
level: string,
|
|
39
|
+
message: string,
|
|
40
|
+
timestamp: number,
|
|
41
|
+
-- Present on errors once the matching stack trace has been correlated.
|
|
42
|
+
stack: string?,
|
|
43
|
+
source: string?,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
local LEVELS: { [number]: string } = {
|
|
47
|
+
[Enum.MessageType.MessageOutput.Value] = "print",
|
|
48
|
+
[Enum.MessageType.MessageInfo.Value] = "info",
|
|
49
|
+
[Enum.MessageType.MessageWarning.Value] = "warning",
|
|
50
|
+
[Enum.MessageType.MessageError.Value] = "error",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
local LogBuffer = {}
|
|
54
|
+
|
|
55
|
+
local entries: { Entry } = {}
|
|
56
|
+
local dropped = 0
|
|
57
|
+
local started = false
|
|
58
|
+
local startedAt = 0
|
|
59
|
+
|
|
60
|
+
--[[
|
|
61
|
+
Stack traces that arrived before the error line they describe.
|
|
62
|
+
|
|
63
|
+
`ScriptContext.Error` and `LogService.MessageOut` are independent events for
|
|
64
|
+
the same failure and there is no guaranteed order between them, so the trace
|
|
65
|
+
is held here until its line shows up rather than being dropped for arriving
|
|
66
|
+
first.
|
|
67
|
+
]]
|
|
68
|
+
local orphanedStacks: { [string]: { stack: string, source: string? } } = {}
|
|
69
|
+
local orphanCount = 0
|
|
70
|
+
|
|
71
|
+
--[[
|
|
72
|
+
The engine also writes each trace to the log as plain lines -- "Stack Begin",
|
|
73
|
+
one "Script 'X', Line N" per frame, then "Stack End" -- which is how the
|
|
74
|
+
Output window renders it. Having attached the trace to its error already,
|
|
75
|
+
echoing those lines as ordinary entries is duplication that reads like
|
|
76
|
+
unrelated output sitting under the failure.
|
|
77
|
+
|
|
78
|
+
They are collected instead of stored, and serve as the fallback source of a
|
|
79
|
+
trace when ScriptContext.Error is unavailable.
|
|
80
|
+
]]
|
|
81
|
+
local STACK_OPEN = "Stack Begin"
|
|
82
|
+
local STACK_CLOSE = "Stack End"
|
|
83
|
+
|
|
84
|
+
-- If a close never arrives -- a place that prints "Stack Begin" itself -- the
|
|
85
|
+
-- collected lines are released rather than swallowed indefinitely.
|
|
86
|
+
local MAX_STACK_LINES = 60
|
|
87
|
+
|
|
88
|
+
local collecting: { string }? = nil
|
|
89
|
+
|
|
90
|
+
local function pushEntry(entry: Entry)
|
|
91
|
+
table.insert(entries, entry)
|
|
92
|
+
|
|
93
|
+
if #entries > CAPACITY + SLACK then
|
|
94
|
+
local excess = #entries - CAPACITY
|
|
95
|
+
entries = table.move(entries, excess + 1, #entries, 1, {})
|
|
96
|
+
dropped += excess
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
local function push(message: string, messageType: Enum.MessageType, timestamp: number?)
|
|
101
|
+
local level = LEVELS[messageType.Value] or "print"
|
|
102
|
+
local entry: Entry = {
|
|
103
|
+
level = level,
|
|
104
|
+
message = message,
|
|
105
|
+
timestamp = timestamp or os.time(),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
-- Claim a trace that beat its own error line here.
|
|
109
|
+
if level == "error" then
|
|
110
|
+
local waiting = orphanedStacks[message]
|
|
111
|
+
if waiting then
|
|
112
|
+
entry.stack = waiting.stack
|
|
113
|
+
entry.source = waiting.source
|
|
114
|
+
orphanedStacks[message] = nil
|
|
115
|
+
orphanCount -= 1
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
pushEntry(entry)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
--[[
|
|
123
|
+
Attaches a stack trace to the error it belongs to.
|
|
124
|
+
|
|
125
|
+
Correlated by message text because the engine offers nothing better: the two
|
|
126
|
+
events carry no shared identifier. Matching the most recent unattached error
|
|
127
|
+
with the same text is right in every case that matters, and the worst outcome
|
|
128
|
+
if two identical errors interleave is that a trace lands on the wrong one of
|
|
129
|
+
two identical lines.
|
|
130
|
+
]]
|
|
131
|
+
local function attachStack(message: string, stack: string, source: string?)
|
|
132
|
+
local first = math.max(1, #entries - CORRELATION_WINDOW + 1)
|
|
133
|
+
for index = #entries, first, -1 do
|
|
134
|
+
local entry = entries[index]
|
|
135
|
+
if entry.level == "error" and entry.message == message and entry.stack == nil then
|
|
136
|
+
entry.stack = stack
|
|
137
|
+
entry.source = source
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
-- Not logged yet. Held for the line to claim, with a cap so a flood of
|
|
143
|
+
-- errors that never surface cannot grow this without bound.
|
|
144
|
+
if orphanCount < CAPACITY then
|
|
145
|
+
if orphanedStacks[message] == nil then
|
|
146
|
+
orphanCount += 1
|
|
147
|
+
end
|
|
148
|
+
orphanedStacks[message] = { stack = stack, source = source }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
--[[
|
|
153
|
+
Begins recording. Safe to call more than once; only the first call connects,
|
|
154
|
+
so a reload cannot end up with two subscriptions writing every line twice.
|
|
155
|
+
]]
|
|
156
|
+
function LogBuffer.start()
|
|
157
|
+
if started then
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
started = true
|
|
161
|
+
startedAt = os.time()
|
|
162
|
+
|
|
163
|
+
-- Seeded before subscribing so the backlog stays in order ahead of live
|
|
164
|
+
-- lines. MessageOut only fires for messages after the connection, so nothing
|
|
165
|
+
-- is counted twice.
|
|
166
|
+
local ok, history = pcall(function()
|
|
167
|
+
return LogService:GetLogHistory()
|
|
168
|
+
end)
|
|
169
|
+
if ok and typeof(history) == "table" then
|
|
170
|
+
for _, item in history :: { { [string]: any } } do
|
|
171
|
+
local kind = item.messageType
|
|
172
|
+
if item.message ~= nil and typeof(kind) == "EnumItem" then
|
|
173
|
+
push(tostring(item.message), kind :: Enum.MessageType, item.timestamp)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
LogService.MessageOut:Connect(function(message: string, messageType: Enum.MessageType)
|
|
179
|
+
local buffered = collecting
|
|
180
|
+
|
|
181
|
+
if buffered then
|
|
182
|
+
if message == STACK_CLOSE then
|
|
183
|
+
collecting = nil
|
|
184
|
+
--[[
|
|
185
|
+
ScriptContext.Error is preferred -- it names the originating
|
|
186
|
+
instance too -- but not blindly. Measured on a nested failure
|
|
187
|
+
the two agree exactly, one frame each, so folding loses
|
|
188
|
+
nothing today. They are still compared by frame count rather
|
|
189
|
+
than trusted to stay equal, because the cost of being wrong
|
|
190
|
+
is a silently shallower trace and the check is one compare.
|
|
191
|
+
]]
|
|
192
|
+
if #buffered > 0 then
|
|
193
|
+
local recent = entries[#entries]
|
|
194
|
+
if recent and recent.level == "error" then
|
|
195
|
+
local existing = recent.stack
|
|
196
|
+
local existingFrames = if existing then #string.split(existing, "\n") else 0
|
|
197
|
+
if existing == nil or #buffered > existingFrames then
|
|
198
|
+
recent.stack = table.concat(buffered, "\n")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
return
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
if #buffered < MAX_STACK_LINES then
|
|
206
|
+
table.insert(buffered, message)
|
|
207
|
+
return
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
-- Not a stack after all. Release what was held, in order, and carry
|
|
211
|
+
-- on treating this as ordinary output.
|
|
212
|
+
collecting = nil
|
|
213
|
+
pushEntry({ level = "print", message = STACK_OPEN, timestamp = os.time() })
|
|
214
|
+
for _, held in buffered do
|
|
215
|
+
pushEntry({ level = "print", message = held, timestamp = os.time() })
|
|
216
|
+
end
|
|
217
|
+
elseif message == STACK_OPEN then
|
|
218
|
+
collecting = {}
|
|
219
|
+
return
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
push(message, messageType)
|
|
223
|
+
end)
|
|
224
|
+
|
|
225
|
+
--[[
|
|
226
|
+
Stack traces, which MessageOut does not carry.
|
|
227
|
+
|
|
228
|
+
Without this an error in the log is a message and nothing else, while the
|
|
229
|
+
trace arrives separately as loose "Stack Begin / Script X, Line N / Stack
|
|
230
|
+
End" prints that read as unrelated output. Attaching it to the error turns
|
|
231
|
+
three ambiguous lines into one answer with a script and a line number.
|
|
232
|
+
|
|
233
|
+
`ErrorDetailed` would be the richer source and is closed to plugins --
|
|
234
|
+
it needs the RobloxScript capability. `Error` is reachable and carries
|
|
235
|
+
message, trace and originating script, which is the part that matters.
|
|
236
|
+
]]
|
|
237
|
+
pcall(function()
|
|
238
|
+
ScriptContext.Error:Connect(function(message: string, stack: string, source: Instance?)
|
|
239
|
+
local origin: string? = nil
|
|
240
|
+
if typeof(source) == "Instance" then
|
|
241
|
+
local named = pcall(function()
|
|
242
|
+
origin = source:GetFullName()
|
|
243
|
+
end)
|
|
244
|
+
if not named then
|
|
245
|
+
origin = source.Name
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
attachStack(message, stack, origin)
|
|
249
|
+
end)
|
|
250
|
+
end)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
--[[
|
|
254
|
+
Everything held, oldest first, plus how much fell off the front. Returned as a
|
|
255
|
+
copy so a caller filtering it cannot disturb the buffer.
|
|
256
|
+
]]
|
|
257
|
+
function LogBuffer.all(): ({ Entry }, number)
|
|
258
|
+
return table.move(entries, 1, #entries, 1, {}), dropped
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
--[[
|
|
262
|
+
How long this buffer has been recording, in seconds.
|
|
263
|
+
|
|
264
|
+
An empty log is ambiguous without it. In a playtest the plugin loads at the
|
|
265
|
+
same moment as the place's own scripts, so a script that logs on startup can
|
|
266
|
+
beat the subscription -- and "nothing was logged" then looks identical to
|
|
267
|
+
"recording began after the thing you are asking about". Reporting the window
|
|
268
|
+
lets the two be told apart instead of guessed at.
|
|
269
|
+
]]
|
|
270
|
+
function LogBuffer.recordingSeconds(): number
|
|
271
|
+
if not started then
|
|
272
|
+
return 0
|
|
273
|
+
end
|
|
274
|
+
return math.max(0, os.time() - startedAt)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
return LogBuffer
|