@el4cteo/rbx-studio-mcp 0.7.1 → 0.7.5
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/api.js +8 -8
- package/dist/bridge/api.js.map +1 -1
- package/dist/bridge/console.js +28 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/bridge/rpc.js +18 -18
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +17 -21
- package/dist/bridge/server.js.map +1 -1
- package/dist/lib/opencloud.js +7 -7
- package/dist/lib/opencloud.js.map +1 -1
- package/dist/lib/tool.js +7 -0
- package/dist/lib/tool.js.map +1 -1
- package/dist/tools/debug.js +15 -4
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/exec.js +11 -6
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +7 -1
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/playtest.js +11 -1
- package/dist/tools/playtest.js.map +1 -1
- package/dist/tools/scripts.js +55 -12
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/world.js +0 -7
- package/dist/tools/world.js.map +1 -1
- package/package.json +75 -74
- package/plugin/src/ClientRelay.luau +88 -0
- package/plugin/src/Commands.luau +26 -0
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +21 -21
- package/plugin/src/Emulation.luau +8 -8
- package/plugin/src/ExecRuntime.luau +161 -0
- package/plugin/src/Phrase.luau +3 -0
- package/plugin/src/RemoteTrace.luau +143 -0
- package/plugin/src/ScriptEdit.luau +14 -14
- package/plugin/src/Secret.luau +0 -9
- package/plugin/src/Transport.luau +58 -1
- package/plugin/src/Visuals.luau +8 -8
- package/plugin/src/handlers/Anim.luau +50 -50
- package/plugin/src/handlers/Character.luau +8 -8
- package/plugin/src/handlers/Debug.luau +574 -504
- package/plugin/src/handlers/Discover.luau +11 -11
- package/plugin/src/handlers/Exec.luau +36 -161
- package/plugin/src/handlers/Input.luau +54 -75
- package/plugin/src/handlers/Instances.luau +5 -5
- package/plugin/src/handlers/Playtest.luau +245 -205
- package/plugin/src/handlers/Scripts.luau +68 -19
- package/plugin/src/init.server.luau +946 -939
- package/scripts/test-bridge.mjs +40 -0
- package/scripts/test-console.mjs +25 -0
- package/scripts/test-failover.mjs +107 -99
- package/scripts/test-live.mjs +283 -0
- package/scripts/test-plugin.mjs +103 -85
- package/scripts/test-tools.mjs +151 -0
- package/scripts/test-transport.mjs +71 -58
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- Shared bounded execution results in the VM that calls run.
|
|
3
|
+
local LogService = game:GetService("LogService")
|
|
4
|
+
local Serialize = require(script.Parent.Serialize)
|
|
5
|
+
-- A chunk that prints in a loop would otherwise return a response no context
|
|
6
|
+
-- window can hold.
|
|
7
|
+
local MAX_OUTPUT_LINES = 200
|
|
8
|
+
local MAX_RETURN_VALUES = 10
|
|
9
|
+
|
|
10
|
+
-- Returned tables are walked rather than counted, but not without limit: a
|
|
11
|
+
-- chunk that returns a whole config tree, or one with a cycle in it, must not be
|
|
12
|
+
-- able to produce a response nothing can read.
|
|
13
|
+
local MAX_TABLE_ENTRIES = 50
|
|
14
|
+
local MAX_TABLE_DEPTH = 4
|
|
15
|
+
|
|
16
|
+
local function describe(value: any, depth: number?, seen: { [any]: boolean }?): any
|
|
17
|
+
local level = depth or 0
|
|
18
|
+
local visited = seen or {}
|
|
19
|
+
local kind = typeof(value)
|
|
20
|
+
|
|
21
|
+
if kind == "function" or kind == "thread" then
|
|
22
|
+
return string.format("<%s>", kind)
|
|
23
|
+
end
|
|
24
|
+
if kind ~= "table" then
|
|
25
|
+
return Serialize.value(value)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
local source = value :: { [any]: any }
|
|
29
|
+
-- Named separately from the depth cap, because they mean opposite things to
|
|
30
|
+
-- a reader. The depth cap says "there is more below this"; a cycle says
|
|
31
|
+
-- "this is the table you are already inside". Reporting the second as the
|
|
32
|
+
-- first printed `t.self.self.self` as three distinct nested tables, which
|
|
33
|
+
-- reads as real structure that does not exist.
|
|
34
|
+
if visited[source] then
|
|
35
|
+
return "<circular reference>"
|
|
36
|
+
end
|
|
37
|
+
if level >= MAX_TABLE_DEPTH then
|
|
38
|
+
return "<nested table>"
|
|
39
|
+
end
|
|
40
|
+
visited[source] = true
|
|
41
|
+
|
|
42
|
+
local count = 0
|
|
43
|
+
for _ in source do
|
|
44
|
+
count += 1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
-- Arrays keep their shape so they read as lists on the other side; anything
|
|
48
|
+
-- with non-sequential keys becomes a map with stringified keys.
|
|
49
|
+
if count == #source then
|
|
50
|
+
local list: { any } = {}
|
|
51
|
+
for index, item in ipairs(source) do
|
|
52
|
+
if index > MAX_TABLE_ENTRIES then
|
|
53
|
+
table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
|
|
54
|
+
break
|
|
55
|
+
end
|
|
56
|
+
table.insert(list, describe(item, level + 1, visited))
|
|
57
|
+
end
|
|
58
|
+
-- Cleared on the way out so the same table appearing twice side by side
|
|
59
|
+
-- still renders twice; only a table containing itself is a cycle.
|
|
60
|
+
visited[source] = nil
|
|
61
|
+
return list
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
local map: { [string]: any } = {}
|
|
65
|
+
local shown = 0
|
|
66
|
+
for key, item in source do
|
|
67
|
+
shown += 1
|
|
68
|
+
if shown > MAX_TABLE_ENTRIES then
|
|
69
|
+
map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
|
|
70
|
+
break
|
|
71
|
+
end
|
|
72
|
+
map[tostring(key)] = describe(item, level + 1, visited)
|
|
73
|
+
end
|
|
74
|
+
visited[source] = nil
|
|
75
|
+
return map
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
local Runtime = {}
|
|
79
|
+
Runtime.describe = describe
|
|
80
|
+
function Runtime.run(chunk: () -> ...any, timeout: number?): { [string]: any }
|
|
81
|
+
-- Captured rather than inferred: a chunk's prints are usually the point, and
|
|
82
|
+
-- reading them back from the log afterwards would also pick up whatever else
|
|
83
|
+
-- the place logged in the meantime.
|
|
84
|
+
local output: { { [string]: any } } = {}
|
|
85
|
+
local connection = LogService.MessageOut:Connect(function(message, messageType)
|
|
86
|
+
if #output < MAX_OUTPUT_LINES then
|
|
87
|
+
table.insert(output, {
|
|
88
|
+
message = message,
|
|
89
|
+
level = if messageType == Enum.MessageType.MessageError
|
|
90
|
+
then "error"
|
|
91
|
+
elseif messageType == Enum.MessageType.MessageWarning then "warning"
|
|
92
|
+
else "print",
|
|
93
|
+
})
|
|
94
|
+
end
|
|
95
|
+
end)
|
|
96
|
+
|
|
97
|
+
local started = os.clock()
|
|
98
|
+
local results: any = nil
|
|
99
|
+
if timeout then
|
|
100
|
+
local worker = task.spawn(function() results = table.pack(pcall(chunk :: any)) end)
|
|
101
|
+
local deadline = started + timeout
|
|
102
|
+
while results == nil and os.clock() < deadline do task.wait() end
|
|
103
|
+
if results == nil then
|
|
104
|
+
task.cancel(worker)
|
|
105
|
+
results = table.pack(false, "CLIENT_TIMEOUT: execution exceeded " .. tostring(timeout) .. "s")
|
|
106
|
+
end
|
|
107
|
+
else
|
|
108
|
+
results = table.pack(pcall(chunk :: () -> ...any))
|
|
109
|
+
end
|
|
110
|
+
local elapsed = os.clock() - started
|
|
111
|
+
|
|
112
|
+
--[[
|
|
113
|
+
MessageOut is deferred: `print` returns before the event fires, so
|
|
114
|
+
disconnecting straight after the chunk finished captured nothing at all.
|
|
115
|
+
A short settle picks the lines up.
|
|
116
|
+
|
|
117
|
+
It is bounded and stops as soon as the flow stops, rather than waiting a
|
|
118
|
+
fixed period, so a chunk that logged nothing costs almost no time and one
|
|
119
|
+
that logged plenty is not truncated.
|
|
120
|
+
]]
|
|
121
|
+
local settleDeadline = os.clock() + 0.5
|
|
122
|
+
local seen = #output
|
|
123
|
+
local quietFrames = 0
|
|
124
|
+
while os.clock() < settleDeadline and quietFrames < 3 do
|
|
125
|
+
task.wait()
|
|
126
|
+
if #output == seen then
|
|
127
|
+
quietFrames += 1
|
|
128
|
+
else
|
|
129
|
+
seen = #output
|
|
130
|
+
quietFrames = 0
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
connection:Disconnect()
|
|
135
|
+
|
|
136
|
+
if not results[1] then
|
|
137
|
+
return {
|
|
138
|
+
ok = false,
|
|
139
|
+
error = tostring(results[2]),
|
|
140
|
+
output = output,
|
|
141
|
+
milliseconds = math.floor(elapsed * 1000 + 0.5),
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
local returned: { any } = {}
|
|
146
|
+
for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
|
|
147
|
+
-- A nil return is written out rather than dropped. `table.insert` of nil
|
|
148
|
+
-- does nothing, so `return a, nil, b` came back as two values and every
|
|
149
|
+
-- one after the nil shifted into the wrong position.
|
|
150
|
+
local value = results[index]
|
|
151
|
+
table.insert(returned, if value == nil then "nil" else describe(value))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
ok = true,
|
|
156
|
+
returned = returned,
|
|
157
|
+
output = output,
|
|
158
|
+
milliseconds = math.floor(elapsed * 1000 + 0.5),
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
return Runtime
|
package/plugin/src/Phrase.luau
CHANGED
|
@@ -288,6 +288,9 @@ local DESCRIBERS: { [string]: Describer } = {
|
|
|
288
288
|
local name = leaf(params.path)
|
|
289
289
|
return if name ~= nil then "Clear breakpoints in " .. name else "Clear all breakpoints"
|
|
290
290
|
end,
|
|
291
|
+
["debug.remotes"] = function()
|
|
292
|
+
return "Observe playtest RemoteEvent traffic"
|
|
293
|
+
end,
|
|
291
294
|
["debug.snapshots"] = function()
|
|
292
295
|
return "Read what the breakpoints caught"
|
|
293
296
|
end,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- Passive, bounded RemoteEvent observations shared by the server and client.
|
|
3
|
+
local Paths = require(script.Parent.Paths)
|
|
4
|
+
local Trace = {}
|
|
5
|
+
local MAX_EVENTS = 1000 -- per direction; disconnect at the cap, never keep raw payloads
|
|
6
|
+
local MAX_REMOTES = 128
|
|
7
|
+
local MAX_ROWS = 20
|
|
8
|
+
local MAX_VISITS = 10000
|
|
9
|
+
|
|
10
|
+
local function brief(value: string, limit: number): string
|
|
11
|
+
local clipped = string.sub(value, 1, limit)
|
|
12
|
+
-- ASCII keeps invalid UTF-8, controls and JSON escaping from expanding output.
|
|
13
|
+
clipped = string.gsub(clipped, "[^ -~]", "?")
|
|
14
|
+
return clipped .. (if #value > limit then "..." else "")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
local function pathOf(remote: Instance): string
|
|
18
|
+
local path = Paths.of(remote)
|
|
19
|
+
if #path <= 256 then return path end
|
|
20
|
+
-- Keep Unicode instance names valid at the byte boundary.
|
|
21
|
+
local ok, boundary = pcall(utf8.offset, path, 0, 257)
|
|
22
|
+
return string.sub(path, 1, if ok and boundary then boundary - 1 else 256) .. "..."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
local function shape(value: any, depth: number): string
|
|
26
|
+
local kind = typeof(value)
|
|
27
|
+
if kind == "string" then return "string:" .. string.format("%q", brief(value, 32)) end
|
|
28
|
+
if kind == "number" or kind == "boolean" or kind == "nil" then return kind .. ":" .. tostring(value) end
|
|
29
|
+
if kind == "Instance" then return "Instance<" .. value.ClassName .. ">" end
|
|
30
|
+
if kind ~= "table" then return kind end
|
|
31
|
+
if depth >= 2 then return "table{...}" end
|
|
32
|
+
local parts = {}
|
|
33
|
+
local key, item = next(value)
|
|
34
|
+
for _ = 1, 4 do
|
|
35
|
+
if key == nil then break end
|
|
36
|
+
local label = if typeof(key) == "string" then brief(key, 20) else typeof(key)
|
|
37
|
+
table.insert(parts, label .. "=" .. shape(item, depth + 1))
|
|
38
|
+
key, item = next(value, key)
|
|
39
|
+
end
|
|
40
|
+
if key ~= nil then table.insert(parts, "...") end
|
|
41
|
+
return brief("table{" .. table.concat(parts, ",") .. "}", 160)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
function Trace.arguments(...: any): string
|
|
45
|
+
local parts = {}
|
|
46
|
+
local count = select("#", ...)
|
|
47
|
+
for index = 1, math.min(count, 6) do
|
|
48
|
+
table.insert(parts, shape(select(index, ...), 0))
|
|
49
|
+
end
|
|
50
|
+
if count > 6 then table.insert(parts, "...") end
|
|
51
|
+
return brief("(" .. table.concat(parts, ", ") .. ")", 192)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
-- Returns an idempotent stop function; its deadline also cleans up if the caller fails.
|
|
55
|
+
function Trace.start(root: Instance, player: Player, client: boolean, seconds: number, excluded: Instance?): () -> { [string]: any }
|
|
56
|
+
local connections: { RBXScriptConnection } = {}
|
|
57
|
+
local watched: { [Instance]: boolean } = {}
|
|
58
|
+
local rows: { any } = {}
|
|
59
|
+
local byRemote: { [Instance]: any } = {}
|
|
60
|
+
local events, remotes, visits = 0, 0, 0
|
|
61
|
+
local omitted, scanLimited, eventLimited = false, false, false
|
|
62
|
+
local skipped = 0
|
|
63
|
+
local active = true
|
|
64
|
+
local started = os.clock()
|
|
65
|
+
local elapsed = 0
|
|
66
|
+
local timer: thread? = nil
|
|
67
|
+
local function stop(): { [string]: any }
|
|
68
|
+
if active then
|
|
69
|
+
active = false
|
|
70
|
+
elapsed = math.max(os.clock() - started, 0.001)
|
|
71
|
+
for _, connection in connections do connection:Disconnect() end
|
|
72
|
+
if timer then task.cancel(timer); timer = nil end
|
|
73
|
+
for _, row in rows do row.callsPerSecond = math.round(row.count / elapsed * 100) / 100 end
|
|
74
|
+
end
|
|
75
|
+
return { items = rows, events = events, seconds = elapsed, watched = remotes,
|
|
76
|
+
eventLimitReached = eventLimited, scanLimitReached = scanLimited, rowsOmitted = omitted, skipped = skipped }
|
|
77
|
+
end
|
|
78
|
+
local function record(remote: Instance, ...: any)
|
|
79
|
+
if not active or (remote ~= root and not remote:IsDescendantOf(root)) then return end
|
|
80
|
+
events += 1
|
|
81
|
+
local row = byRemote[remote]
|
|
82
|
+
if row == nil and #rows < MAX_ROWS then
|
|
83
|
+
row = { path = pathOf(remote), direction = if client then "server -> client" else "client -> server",
|
|
84
|
+
player = brief(player.Name, 64), count = 0, samples = {} }
|
|
85
|
+
byRemote[remote] = row
|
|
86
|
+
table.insert(rows, row)
|
|
87
|
+
end
|
|
88
|
+
if row then
|
|
89
|
+
row.count += 1
|
|
90
|
+
if #row.samples < 2 then
|
|
91
|
+
local sample = Trace.arguments(...)
|
|
92
|
+
if row.samples[1] ~= sample then table.insert(row.samples, sample) end
|
|
93
|
+
end
|
|
94
|
+
else omitted = true end
|
|
95
|
+
if events >= MAX_EVENTS then eventLimited = true; stop() end
|
|
96
|
+
end
|
|
97
|
+
local function attach(remote: Instance)
|
|
98
|
+
if not active or watched[remote] or not remote:IsA("RemoteEvent") then return end
|
|
99
|
+
if excluded and (remote == excluded or remote:IsDescendantOf(excluded)) then return end
|
|
100
|
+
-- Ignore all temporary bridge traffic, including overlapping MCP calls.
|
|
101
|
+
local ancestor: Instance? = remote.Parent
|
|
102
|
+
while ancestor and ancestor ~= game do
|
|
103
|
+
if ancestor:GetAttribute("MCPClientRelay") == true then return end
|
|
104
|
+
ancestor = ancestor.Parent
|
|
105
|
+
end
|
|
106
|
+
if remotes >= MAX_REMOTES then scanLimited = true; return end
|
|
107
|
+
watched[remote] = true
|
|
108
|
+
remotes += 1
|
|
109
|
+
if client then
|
|
110
|
+
table.insert(connections, (remote :: RemoteEvent).OnClientEvent:Connect(function(...) record(remote, ...) end))
|
|
111
|
+
else
|
|
112
|
+
table.insert(connections, (remote :: RemoteEvent).OnServerEvent:Connect(function(from, ...)
|
|
113
|
+
if from == player then record(remote, ...) end
|
|
114
|
+
end))
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
local ok, failure = pcall(function()
|
|
118
|
+
-- Subscribe first, then scan so remotes created during discovery are not missed.
|
|
119
|
+
table.insert(connections, root.DescendantAdded:Connect(function(object)
|
|
120
|
+
if visits >= MAX_VISITS then scanLimited = true; return end
|
|
121
|
+
visits += 1
|
|
122
|
+
local attached = pcall(attach, object)
|
|
123
|
+
if not attached then skipped += 1 end
|
|
124
|
+
end))
|
|
125
|
+
local pending = { root }
|
|
126
|
+
while #pending > 0 and visits < MAX_VISITS do
|
|
127
|
+
local object = table.remove(pending) :: Instance
|
|
128
|
+
visits += 1
|
|
129
|
+
attach(object)
|
|
130
|
+
local readable, children = pcall(object.GetChildren, object)
|
|
131
|
+
if not readable then skipped += 1; continue end
|
|
132
|
+
for _, child in children do
|
|
133
|
+
if visits + #pending >= MAX_VISITS then scanLimited = true; break end
|
|
134
|
+
table.insert(pending, child)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
if #pending > 0 then scanLimited = true end
|
|
138
|
+
timer = task.delay(seconds, function() timer = nil; stop() end)
|
|
139
|
+
end)
|
|
140
|
+
if not ok then stop(); error(failure, 0) end
|
|
141
|
+
return stop
|
|
142
|
+
end
|
|
143
|
+
return Trace
|
|
@@ -38,20 +38,6 @@ function ScriptEdit.read(target: LuaSourceContainer): string
|
|
|
38
38
|
return (target :: SourceContainer).Source
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
--[[
|
|
42
|
-
Replaces source through the editor. `transform` receives the current text and
|
|
43
|
-
returns the replacement; returning the input unchanged is a no-op.
|
|
44
|
-
|
|
45
|
-
`transform` is allowed to raise a structured Dispatch failure -- a bad line
|
|
46
|
-
range is only discoverable once the authoritative source is in hand. Such a
|
|
47
|
-
failure is stashed rather than thrown from inside the callback: throwing
|
|
48
|
-
there would surface as an opaque engine error and lose the code and hint the
|
|
49
|
-
agent needs. The callback instead returns the text untouched, making that one
|
|
50
|
-
script a no-op, and the original failure is re-raised afterwards.
|
|
51
|
-
|
|
52
|
-
Raises SCRIPT_LOCKED when the write is refused, which normally means the
|
|
53
|
-
script is a package member or is owned by another Team Create session.
|
|
54
|
-
]]
|
|
55
41
|
--[[
|
|
56
42
|
Makes sure a write that reported success actually stuck.
|
|
57
43
|
|
|
@@ -97,6 +83,20 @@ local function confirm(target: LuaSourceContainer, produced: string?)
|
|
|
97
83
|
end
|
|
98
84
|
end
|
|
99
85
|
|
|
86
|
+
--[[
|
|
87
|
+
Replaces source through the editor. `transform` receives the current text and
|
|
88
|
+
returns the replacement; returning the input unchanged is a no-op.
|
|
89
|
+
|
|
90
|
+
`transform` is allowed to raise a structured Dispatch failure -- a bad line
|
|
91
|
+
range is only discoverable once the authoritative source is in hand. Such a
|
|
92
|
+
failure is stashed rather than thrown from inside the callback: throwing
|
|
93
|
+
there would surface as an opaque engine error and lose the code and hint the
|
|
94
|
+
agent needs. The callback instead returns the text untouched, making that one
|
|
95
|
+
script a no-op, and the original failure is re-raised afterwards.
|
|
96
|
+
|
|
97
|
+
Raises SCRIPT_LOCKED when the write is refused, which normally means the
|
|
98
|
+
script is a package member or is owned by another Team Create session.
|
|
99
|
+
]]
|
|
100
100
|
function ScriptEdit.write(target: LuaSourceContainer, transform: (string) -> string)
|
|
101
101
|
local pending: any = nil
|
|
102
102
|
|
package/plugin/src/Secret.luau
CHANGED
|
@@ -22,15 +22,6 @@
|
|
|
22
22
|
|
|
23
23
|
local Secret = {}
|
|
24
24
|
|
|
25
|
-
--[[
|
|
26
|
-
Commands whose arguments are secret, and from which word onwards.
|
|
27
|
-
|
|
28
|
-
Keyed by "command subcommand" so `cloud key <secret>` is covered and `cloud
|
|
29
|
-
test` -- which carries nothing -- is not masked into uselessness. The user id
|
|
30
|
-
is masked too. It is not a credential and it is on a public profile URL, but
|
|
31
|
-
it names the account the key belongs to, and a screenshot showing both is
|
|
32
|
-
worth more to somebody than a screenshot showing either.
|
|
33
|
-
]]
|
|
34
25
|
--[[
|
|
35
26
|
Commands whose arguments are secret, and from which word onwards.
|
|
36
27
|
|
|
@@ -150,6 +150,61 @@ local function parseFrames(message: string): { { [string]: any } }
|
|
|
150
150
|
return frames
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
+
--[[
|
|
154
|
+
Most bytes one stream may hold while waiting for the end of an event. Far
|
|
155
|
+
above any real command -- the bridge caps bodies at 32MB -- and only there so
|
|
156
|
+
a stream that never sends a blank line cannot grow this without limit.
|
|
157
|
+
]]
|
|
158
|
+
local MAX_PENDING = 48 * 1024 * 1024
|
|
159
|
+
|
|
160
|
+
--[[
|
|
161
|
+
Joins stream deliveries back into whole events before parsing them.
|
|
162
|
+
|
|
163
|
+
`MessageReceived` hands over whatever the socket read, not one event: two
|
|
164
|
+
small frames can share a delivery, and one big frame is split over several.
|
|
165
|
+
Parsing each delivery alone dropped every split frame, because half a JSON
|
|
166
|
+
document does not decode -- a `script_create` carrying a 16KB script vanished
|
|
167
|
+
and the call timed out with nothing logged on either side, while a 32KB one
|
|
168
|
+
that happened to land in one read worked.
|
|
169
|
+
|
|
170
|
+
So everything up to the last blank line is parsed and the rest is held for
|
|
171
|
+
the next delivery. A tail that already decodes on its own is taken at once,
|
|
172
|
+
which keeps this correct if Studio ever delivers events without their
|
|
173
|
+
trailing blank line.
|
|
174
|
+
]]
|
|
175
|
+
local function frameReader(): (chunk: string) -> { { [string]: any } }
|
|
176
|
+
local pending = ""
|
|
177
|
+
|
|
178
|
+
return function(chunk: string): { { [string]: any } }
|
|
179
|
+
-- Normalised after joining, so a CRLF split across two reads still folds.
|
|
180
|
+
pending = (string.gsub(pending .. chunk, "\r\n", "\n"))
|
|
181
|
+
|
|
182
|
+
local frames: { { [string]: any } } = {}
|
|
183
|
+
local lastBreak = string.match(pending, "^.*()\n\n")
|
|
184
|
+
if lastBreak ~= nil then
|
|
185
|
+
frames = parseFrames(string.sub(pending, 1, lastBreak - 1))
|
|
186
|
+
pending = string.sub(pending, lastBreak + 2)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
-- A partial JSON document never ends in "}", so the decode is only
|
|
190
|
+
-- attempted when it could succeed rather than on every read of a big one.
|
|
191
|
+
if pending ~= "" and string.match(pending, "}%s*$") then
|
|
192
|
+
local tail = parseFrames(pending)
|
|
193
|
+
if #tail > 0 then
|
|
194
|
+
for _, frame in tail do
|
|
195
|
+
table.insert(frames, frame)
|
|
196
|
+
end
|
|
197
|
+
pending = ""
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
if #pending > MAX_PENDING then
|
|
202
|
+
pending = ""
|
|
203
|
+
end
|
|
204
|
+
return frames
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
153
208
|
local function handleFrame(callbacks: Callbacks, frame: { [string]: any })
|
|
154
209
|
local id = frame.id
|
|
155
210
|
local op = frame.op
|
|
@@ -196,10 +251,12 @@ local function runStream(callbacks: Callbacks): boolean
|
|
|
196
251
|
end)
|
|
197
252
|
)
|
|
198
253
|
|
|
254
|
+
-- One per stream: a partial event must never be glued onto the next stream.
|
|
255
|
+
local readFrames = frameReader()
|
|
199
256
|
table.insert(
|
|
200
257
|
connections,
|
|
201
258
|
client.MessageReceived:Connect(function(message: string)
|
|
202
|
-
for _, frame in
|
|
259
|
+
for _, frame in readFrames(message) do
|
|
203
260
|
handleFrame(callbacks, frame)
|
|
204
261
|
end
|
|
205
262
|
end)
|
package/plugin/src/Visuals.luau
CHANGED
|
@@ -624,14 +624,6 @@ function Visuals.celebrate()
|
|
|
624
624
|
runtime.waveHold = 0
|
|
625
625
|
end
|
|
626
626
|
|
|
627
|
-
--[[
|
|
628
|
-
Re-places every bar and hands each to the active preset to draw.
|
|
629
|
-
|
|
630
|
-
Laid out right to left so the newest bar is always at the same edge and the
|
|
631
|
-
trace reads as scrolling rather than reshuffling. The horizontal position is
|
|
632
|
-
set here and the preset is expected to keep it -- everything else about the
|
|
633
|
-
bar is the preset's to decide.
|
|
634
|
-
]]
|
|
635
627
|
--[[
|
|
636
628
|
Where slot `number` sits, counting 1 at the right edge and rising leftwards.
|
|
637
629
|
|
|
@@ -657,6 +649,14 @@ end
|
|
|
657
649
|
it. Absent, every bar is drawn from its own recorded timing, which is what
|
|
658
650
|
restores the true trace the moment a wave ends.
|
|
659
651
|
]]
|
|
652
|
+
--[[
|
|
653
|
+
Re-places every bar and hands each to the active preset to draw.
|
|
654
|
+
|
|
655
|
+
Laid out right to left so the newest bar is always at the same edge and the
|
|
656
|
+
trace reads as scrolling rather than reshuffling. The horizontal position is
|
|
657
|
+
set here and the preset is expected to keep it -- everything else about the
|
|
658
|
+
bar is the preset's to decide.
|
|
659
|
+
]]
|
|
660
660
|
local function relayout(modulate: ((number, number, number) -> (number, number))?)
|
|
661
661
|
local ctx = context()
|
|
662
662
|
local painter = Themes.active().paintSlot or defaultSlot
|
|
@@ -130,22 +130,6 @@ local function flatten(node: Instance, prefix: string, into: { { [string]: any }
|
|
|
130
130
|
end
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
-
--[[
|
|
134
|
-
Which rig an animation was made for, read off the joints it moves.
|
|
135
|
-
|
|
136
|
-
This matters more than it sounds. An R15 animation played on an R6 character
|
|
137
|
-
does nothing at all -- no error, no warning, no movement -- because the joint
|
|
138
|
-
names it addresses ("LeftUpperArm") do not exist on that rig ("Left Arm").
|
|
139
|
-
It is one of the quietest failures on the platform, and the asset id carries
|
|
140
|
-
no hint of which kind it is.
|
|
141
|
-
|
|
142
|
-
The two skeletons are told apart by naming rather than by counting. R6 uses
|
|
143
|
-
six limbs with spaces in their names, unchanged since 2007. R15 splits each
|
|
144
|
-
limb in two and writes them without spaces. Anything that matches neither is
|
|
145
|
-
a custom rig, and is reported as such rather than guessed at -- a wrong
|
|
146
|
-
confident answer here is worse than "unknown", because the whole point of
|
|
147
|
-
the field is to be trusted.
|
|
148
|
-
]]
|
|
149
133
|
--[[
|
|
150
134
|
Which joint hangs off which, for the two rigs Roblox ships.
|
|
151
135
|
|
|
@@ -207,6 +191,22 @@ local R15_PARTS = {
|
|
|
207
191
|
RightLowerArm = true,
|
|
208
192
|
}
|
|
209
193
|
|
|
194
|
+
--[[
|
|
195
|
+
Which rig an animation was made for, read off the joints it moves.
|
|
196
|
+
|
|
197
|
+
This matters more than it sounds. An R15 animation played on an R6 character
|
|
198
|
+
does nothing at all -- no error, no warning, no movement -- because the joint
|
|
199
|
+
names it addresses ("LeftUpperArm") do not exist on that rig ("Left Arm").
|
|
200
|
+
It is one of the quietest failures on the platform, and the asset id carries
|
|
201
|
+
no hint of which kind it is.
|
|
202
|
+
|
|
203
|
+
The two skeletons are told apart by naming rather than by counting. R6 uses
|
|
204
|
+
six limbs with spaces in their names, unchanged since 2007. R15 splits each
|
|
205
|
+
limb in two and writes them without spaces. Anything that matches neither is
|
|
206
|
+
a custom rig, and is reported as such rather than guessed at -- a wrong
|
|
207
|
+
confident answer here is worse than "unknown", because the whole point of
|
|
208
|
+
the field is to be trusted.
|
|
209
|
+
]]
|
|
210
210
|
local function rigOf(joints: { [string]: boolean }): (string, string?)
|
|
211
211
|
local r6, r15 = 0, 0
|
|
212
212
|
for joint in joints do
|
|
@@ -418,21 +418,6 @@ function Anim.read(params: { [string]: any }): { [string]: any }
|
|
|
418
418
|
}
|
|
419
419
|
end
|
|
420
420
|
|
|
421
|
-
--[[
|
|
422
|
-
Builds a KeyframeSequence in memory and registers it for playback.
|
|
423
|
-
|
|
424
|
-
The registration is what makes this more than an instance tree: it returns a
|
|
425
|
-
content id that an Animation can use immediately, in this Studio, with no
|
|
426
|
-
upload and no moderation wait. That is the whole loop an agent needs to try
|
|
427
|
-
an idea -- build, set AnimationId, look -- and it is entirely reversible,
|
|
428
|
-
because nothing left the session.
|
|
429
|
-
|
|
430
|
-
Poses arrive flat, named by joint, and are nested under one root pose here.
|
|
431
|
-
Flat is what a caller can write and nesting is what the engine resolves, and
|
|
432
|
-
the rig's own hierarchy is not knowable from a list of joint names -- so a
|
|
433
|
-
single root is the honest structure: it animates the named joints and claims
|
|
434
|
-
nothing about how they connect.
|
|
435
|
-
]]
|
|
436
421
|
--[[
|
|
437
422
|
The joint tree to nest poses against.
|
|
438
423
|
|
|
@@ -477,6 +462,21 @@ local function parentMap(params: { [string]: any }, joints: { [string]: boolean
|
|
|
477
462
|
return R6_PARENT, "R6"
|
|
478
463
|
end
|
|
479
464
|
|
|
465
|
+
--[[
|
|
466
|
+
Builds a KeyframeSequence in memory and registers it for playback.
|
|
467
|
+
|
|
468
|
+
The registration is what makes this more than an instance tree: it returns a
|
|
469
|
+
content id that an Animation can use immediately, in this Studio, with no
|
|
470
|
+
upload and no moderation wait. That is the whole loop an agent needs to try
|
|
471
|
+
an idea -- build, set AnimationId, look -- and it is entirely reversible,
|
|
472
|
+
because nothing left the session.
|
|
473
|
+
|
|
474
|
+
Poses arrive flat, named by joint, and are nested under one root pose here.
|
|
475
|
+
Flat is what a caller can write and nesting is what the engine resolves, and
|
|
476
|
+
the rig's own hierarchy is not knowable from a list of joint names -- so a
|
|
477
|
+
single root is the honest structure: it animates the named joints and claims
|
|
478
|
+
nothing about how they connect.
|
|
479
|
+
]]
|
|
480
480
|
function Anim.build(params: { [string]: any }): { [string]: any }
|
|
481
481
|
local frames = params.keyframes
|
|
482
482
|
if typeof(frames) ~= "table" or #(frames :: { any }) == 0 then
|
|
@@ -636,25 +636,6 @@ function Anim.build(params: { [string]: any }): { [string]: any }
|
|
|
636
636
|
}
|
|
637
637
|
end
|
|
638
638
|
|
|
639
|
-
--[[
|
|
640
|
-
Plays an animation on a real rig, in edit mode, so it can be looked at.
|
|
641
|
-
|
|
642
|
-
`read` says what an animation contains and `build` makes one; neither answers
|
|
643
|
-
"does it look right", which for a piece of motion is most of the question.
|
|
644
|
-
Measured: an `Animator` loads and plays a track in an ordinary edit session
|
|
645
|
-
-- `Length` comes back populated and `IsPlaying` is true -- so the rig in the
|
|
646
|
-
place can be posed without pressing Play.
|
|
647
|
-
|
|
648
|
-
The frame is the point. Playing an animation and returning immediately shows
|
|
649
|
-
whatever pose the rig happened to be in, so this seeks to a chosen moment and
|
|
650
|
-
holds it there: play, jump to `at`, pause. Then `screenshot` sees that exact
|
|
651
|
-
pose, and asking for three different moments gives three comparable frames.
|
|
652
|
-
|
|
653
|
-
Deliberately leaves the rig posed rather than restoring it. The whole purpose
|
|
654
|
-
is to leave something on screen to photograph, and a tool that tidied up
|
|
655
|
-
before returning would always photograph the idle pose. `op="stop"` puts it
|
|
656
|
-
back.
|
|
657
|
-
]]
|
|
658
639
|
--[[
|
|
659
640
|
Puts a rig back the way it was before anything was played on it.
|
|
660
641
|
|
|
@@ -722,6 +703,25 @@ local function restPose(model: Instance, animator: Animator)
|
|
|
722
703
|
animator:StepAnimations(1 / 60)
|
|
723
704
|
end
|
|
724
705
|
|
|
706
|
+
--[[
|
|
707
|
+
Plays an animation on a real rig, in edit mode, so it can be looked at.
|
|
708
|
+
|
|
709
|
+
`read` says what an animation contains and `build` makes one; neither answers
|
|
710
|
+
"does it look right", which for a piece of motion is most of the question.
|
|
711
|
+
Measured: an `Animator` loads and plays a track in an ordinary edit session
|
|
712
|
+
-- `Length` comes back populated and `IsPlaying` is true -- so the rig in the
|
|
713
|
+
place can be posed without pressing Play.
|
|
714
|
+
|
|
715
|
+
The frame is the point. Playing an animation and returning immediately shows
|
|
716
|
+
whatever pose the rig happened to be in, so this seeks to a chosen moment and
|
|
717
|
+
holds it there: play, jump to `at`, pause. Then `screenshot` sees that exact
|
|
718
|
+
pose, and asking for three different moments gives three comparable frames.
|
|
719
|
+
|
|
720
|
+
Deliberately leaves the rig posed rather than restoring it. The whole purpose
|
|
721
|
+
is to leave something on screen to photograph, and a tool that tidied up
|
|
722
|
+
before returning would always photograph the idle pose. `op="stop"` puts it
|
|
723
|
+
back.
|
|
724
|
+
]]
|
|
725
725
|
function Anim.preview(params: { [string]: any }): { [string]: any }
|
|
726
726
|
local rigPath = params.rig
|
|
727
727
|
if typeof(rigPath) ~= "string" or rigPath == "" then
|
|
@@ -178,14 +178,6 @@ local function takeControl(model: Model): boolean
|
|
|
178
178
|
return ok
|
|
179
179
|
end
|
|
180
180
|
|
|
181
|
-
--[[
|
|
182
|
-
Walks to a point, following a computed path around obstacles.
|
|
183
|
-
|
|
184
|
-
Reports where it ended up rather than only whether the call returned, because
|
|
185
|
-
`MoveTo` succeeds at being asked and says nothing about arriving. A route
|
|
186
|
-
blocked by a wall the agent did not know about looks identical to a
|
|
187
|
-
successful walk unless the final distance is measured.
|
|
188
|
-
]]
|
|
189
181
|
--[[
|
|
190
182
|
Agent settings, shared by the route check and the walk.
|
|
191
183
|
|
|
@@ -336,6 +328,14 @@ function Character.path(params: { [string]: any }): { [string]: any }
|
|
|
336
328
|
}
|
|
337
329
|
end
|
|
338
330
|
|
|
331
|
+
--[[
|
|
332
|
+
Walks to a point, following a computed path around obstacles.
|
|
333
|
+
|
|
334
|
+
Reports where it ended up rather than only whether the call returned, because
|
|
335
|
+
`MoveTo` succeeds at being asked and says nothing about arriving. A route
|
|
336
|
+
blocked by a wall the agent did not know about looks identical to a
|
|
337
|
+
successful walk unless the final distance is measured.
|
|
338
|
+
]]
|
|
339
339
|
function Character.moveTo(params: { [string]: any }): { [string]: any }
|
|
340
340
|
local model, humanoid = humanoidOf(params)
|
|
341
341
|
|