@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.
Files changed (54) hide show
  1. package/dist/bridge/api.js +8 -8
  2. package/dist/bridge/api.js.map +1 -1
  3. package/dist/bridge/console.js +28 -0
  4. package/dist/bridge/console.js.map +1 -1
  5. package/dist/bridge/rpc.js +18 -18
  6. package/dist/bridge/rpc.js.map +1 -1
  7. package/dist/bridge/server.js +17 -21
  8. package/dist/bridge/server.js.map +1 -1
  9. package/dist/lib/opencloud.js +7 -7
  10. package/dist/lib/opencloud.js.map +1 -1
  11. package/dist/lib/tool.js +7 -0
  12. package/dist/lib/tool.js.map +1 -1
  13. package/dist/tools/debug.js +15 -4
  14. package/dist/tools/debug.js.map +1 -1
  15. package/dist/tools/exec.js +11 -6
  16. package/dist/tools/exec.js.map +1 -1
  17. package/dist/tools/input.js +7 -1
  18. package/dist/tools/input.js.map +1 -1
  19. package/dist/tools/playtest.js +11 -1
  20. package/dist/tools/playtest.js.map +1 -1
  21. package/dist/tools/scripts.js +55 -12
  22. package/dist/tools/scripts.js.map +1 -1
  23. package/dist/tools/world.js +0 -7
  24. package/dist/tools/world.js.map +1 -1
  25. package/package.json +75 -74
  26. package/plugin/src/ClientRelay.luau +88 -0
  27. package/plugin/src/Commands.luau +26 -0
  28. package/plugin/src/Config.luau +1 -1
  29. package/plugin/src/Console.luau +21 -21
  30. package/plugin/src/Emulation.luau +8 -8
  31. package/plugin/src/ExecRuntime.luau +161 -0
  32. package/plugin/src/Phrase.luau +3 -0
  33. package/plugin/src/RemoteTrace.luau +143 -0
  34. package/plugin/src/ScriptEdit.luau +14 -14
  35. package/plugin/src/Secret.luau +0 -9
  36. package/plugin/src/Transport.luau +58 -1
  37. package/plugin/src/Visuals.luau +8 -8
  38. package/plugin/src/handlers/Anim.luau +50 -50
  39. package/plugin/src/handlers/Character.luau +8 -8
  40. package/plugin/src/handlers/Debug.luau +574 -504
  41. package/plugin/src/handlers/Discover.luau +11 -11
  42. package/plugin/src/handlers/Exec.luau +36 -161
  43. package/plugin/src/handlers/Input.luau +54 -75
  44. package/plugin/src/handlers/Instances.luau +5 -5
  45. package/plugin/src/handlers/Playtest.luau +245 -205
  46. package/plugin/src/handlers/Scripts.luau +68 -19
  47. package/plugin/src/init.server.luau +946 -939
  48. package/scripts/test-bridge.mjs +40 -0
  49. package/scripts/test-console.mjs +25 -0
  50. package/scripts/test-failover.mjs +107 -99
  51. package/scripts/test-live.mjs +283 -0
  52. package/scripts/test-plugin.mjs +103 -85
  53. package/scripts/test-tools.mjs +151 -0
  54. 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
@@ -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
 
@@ -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 parseFrames(message) do
259
+ for _, frame in readFrames(message) do
203
260
  handleFrame(callbacks, frame)
204
261
  end
205
262
  end)
@@ -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