@el4cteo/rbx-studio-mcp 0.7.8 → 0.8.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.
Files changed (50) hide show
  1. package/README.md +175 -175
  2. package/dist/bridge/harness.js +14 -5
  3. package/dist/bridge/harness.js.map +1 -1
  4. package/dist/index.js +5 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/apidump.js +38 -23
  7. package/dist/lib/apidump.js.map +1 -1
  8. package/dist/lib/format.js +43 -4
  9. package/dist/lib/format.js.map +1 -1
  10. package/dist/lib/pluginbuild.js +10 -0
  11. package/dist/lib/pluginbuild.js.map +1 -1
  12. package/dist/lib/png.js +104 -0
  13. package/dist/lib/png.js.map +1 -1
  14. package/dist/resources.js +3 -2
  15. package/dist/resources.js.map +1 -1
  16. package/dist/tools/api.js +4 -3
  17. package/dist/tools/api.js.map +1 -1
  18. package/dist/tools/debug.js +2 -2
  19. package/dist/tools/debug.js.map +1 -1
  20. package/dist/tools/device.js +2 -2
  21. package/dist/tools/device.js.map +1 -1
  22. package/dist/tools/discover.js +7 -3
  23. package/dist/tools/discover.js.map +1 -1
  24. package/dist/tools/exec.js +4 -4
  25. package/dist/tools/exec.js.map +1 -1
  26. package/dist/tools/instances.js +32 -7
  27. package/dist/tools/instances.js.map +1 -1
  28. package/dist/tools/screenshot.js +55 -11
  29. package/dist/tools/screenshot.js.map +1 -1
  30. package/dist/tools/scripts.js +4 -4
  31. package/dist/tools/scripts.js.map +1 -1
  32. package/dist/tools/terrain.js +3 -3
  33. package/dist/tools/terrain.js.map +1 -1
  34. package/dist/tools/world.js +8 -8
  35. package/dist/tools/world.js.map +1 -1
  36. package/package.json +75 -75
  37. package/plugin/src/Config.luau +1 -1
  38. package/plugin/src/LogBuffer.luau +7 -0
  39. package/plugin/src/Paths.luau +15 -4
  40. package/plugin/src/Phrase.luau +1 -20
  41. package/plugin/src/ScriptEdit.luau +14 -0
  42. package/plugin/src/Serialize.luau +17 -0
  43. package/plugin/src/TextEdit.luau +6 -0
  44. package/plugin/src/handlers/Api.luau +8 -0
  45. package/plugin/src/handlers/Capture.luau +219 -3
  46. package/plugin/src/init.server.luau +0 -2
  47. package/scripts/test-tools.mjs +259 -216
  48. package/dist/tools/anim.js +0 -159
  49. package/dist/tools/anim.js.map +0 -1
  50. package/plugin/src/handlers/Anim.luau +0 -897
@@ -1,897 +0,0 @@
1
- --!strict
2
- --[[
3
- Animations, as data rather than as a timeline.
4
-
5
- An animation is the one part of a Roblox place this server could not see at
6
- all. `inspect` on an Animation instance returns an asset id and nothing else
7
- -- the poses live on Roblox's servers, and the only way to look at them was
8
- to open the Animation Editor and scrub, which an agent cannot do.
9
-
10
- `KeyframeSequenceProvider` hands the whole thing over as instances:
11
- `GetKeyframeSequenceAsync` downloads any animation as a KeyframeSequence, and
12
- `RegisterKeyframeSequence` goes the other way, turning one built in memory
13
- into a content id that plays. Measured against a live session: Roblox's own
14
- run animation came back as 16 keyframes, and a three-pose sequence built here
15
- registered and returned an id.
16
-
17
- What that makes possible is reading: "this animation is 0.8s long, it moves
18
- the right arm and nothing else, and the last keyframe does not return to the
19
- first" is a sentence nobody could get out of Studio without watching it
20
- frame by frame.
21
-
22
- Nothing here uploads. A registered sequence is local to the running Studio,
23
- which is the correct scope for a preview and useless as a way to publish --
24
- see the `publish` tool for that.
25
- ]]
26
-
27
- local KeyframeSequenceProvider = game:GetService("KeyframeSequenceProvider")
28
-
29
- local Dispatch = require(script.Parent.Parent.Dispatch)
30
- local Paths = require(script.Parent.Parent.Paths)
31
- local Serialize = require(script.Parent.Parent.Serialize)
32
-
33
- local Anim = {}
34
-
35
- --[[
36
- Keyframes returned in full detail before the listing thins out.
37
-
38
- A long animation is hundreds of keyframes and every one carries a pose per
39
- moving joint, so the honest full dump of a walk cycle is larger than the
40
- conversation it would appear in. Past this, times are still all reported --
41
- they are what the shape of an animation is made of -- and only the poses stop
42
- being expanded.
43
- ]]
44
- local DETAIL_LIMIT = 40
45
-
46
- --[[
47
- The asset id, however the caller wrote it.
48
-
49
- Ids reach this server as a number, as "rbxassetid://123", and as an
50
- Animation instance's path, because all three are what someone has in front
51
- of them at the time. Normalising here means the three do not each need their
52
- own branch further down.
53
- ]]
54
- local function contentId(params: { [string]: any }): string
55
- local raw = params.assetId
56
-
57
- --[[
58
- A locally registered sequence, which looks like nothing else.
59
-
60
- `RegisterKeyframeSequence` returns a bare 32-character hash -- no scheme,
61
- no digits-only shape -- so the path branch below claimed it and reported
62
- "no instance at 37f2a7f5...". That made `build` and `read` unable to talk
63
- to each other, which is the first thing anyone would try.
64
-
65
- Checked before the path branch because a 32-character hex string is not a
66
- plausible instance path, and a path that happened to look like one would
67
- still be reachable by writing it with its parent.
68
- ]]
69
- if typeof(raw) == "string" and #raw == 32 and string.match(raw, "^%x+$") ~= nil then
70
- return raw
71
- end
72
-
73
- if typeof(raw) == "string" and string.match(raw, "^%d+$") == nil and string.find(raw, "://") == nil then
74
- -- Not an id at all: a path to an Animation instance in the place.
75
- local instance = Paths.resolve(raw)
76
- if not instance:IsA("Animation") then
77
- Dispatch.fail(
78
- "BAD_PARAMS",
79
- string.format("%s is a %s, not an Animation.", raw, instance.ClassName),
80
- "Pass an animation asset id, or the path of an Animation instance."
81
- )
82
- end
83
- local id = (instance :: Animation).AnimationId
84
- if id == "" then
85
- Dispatch.fail("BAD_PARAMS", string.format("%s has no AnimationId set.", raw))
86
- end
87
- return id
88
- end
89
-
90
- local number = tonumber(raw)
91
- if number ~= nil then
92
- return string.format("rbxassetid://%d", math.floor(number))
93
- end
94
-
95
- if typeof(raw) == "string" and raw ~= "" then
96
- return raw
97
- end
98
-
99
- Dispatch.fail("BAD_PARAMS", "this needs an `assetId` -- a number, an rbxassetid:// string, or a path to an Animation.")
100
- return ""
101
- end
102
-
103
- --[[
104
- Every pose in a keyframe, flattened with its path through the rig.
105
-
106
- Poses nest the way the rig's joints do -- a Pose named "Left Arm" is a child
107
- of the Pose for the part it is jointed to -- and that structure is how the
108
- engine resolves them. It is not how a reader wants them: the question is
109
- "which joints does this animation move", and the answer is a flat list of
110
- names, with the nesting kept as a path so the rig is still legible.
111
- ]]
112
- local function flatten(node: Instance, prefix: string, into: { { [string]: any } })
113
- for _, child in node:GetChildren() do
114
- if not child:IsA("Pose") then
115
- continue
116
- end
117
- local pose = child :: Pose
118
- local name = if prefix == "" then pose.Name else prefix .. "/" .. pose.Name
119
- table.insert(into, {
120
- joint = name,
121
- cframe = Serialize.value(pose.CFrame),
122
- weight = pose.Weight,
123
- easing = string.format(
124
- "%s %s",
125
- tostring(pose.EasingStyle):gsub("Enum%.PoseEasingStyle%.", ""),
126
- tostring(pose.EasingDirection):gsub("Enum%.PoseEasingDirection%.", "")
127
- ),
128
- })
129
- flatten(pose, name, into)
130
- end
131
- end
132
-
133
- --[[
134
- Which joint hangs off which, for the two rigs Roblox ships.
135
-
136
- A KeyframeSequence is not a flat list of poses: the engine matches the POSE
137
- TREE against the rig's joint tree, and a pose whose parent does not join it
138
- in the rig is ignored -- silently, with no error and no movement. `build`
139
- used to parent every pose directly under the root, so an animation naming
140
- "Right Arm" produced an id that loaded, played, reported weight 1, and moved
141
- nothing at all. Measured on an R6 rig: Motor6D.Transform stayed identity
142
- through every frame.
143
-
144
- So poses have to be nested, and nesting needs to know the rig. Read off a
145
- real rig when one is named, and otherwise off these -- they are the standard
146
- characters and they do not change.
147
- ]]
148
- local R6_PARENT: { [string]: string } = {
149
- Torso = "HumanoidRootPart",
150
- Head = "Torso",
151
- ["Left Arm"] = "Torso",
152
- ["Right Arm"] = "Torso",
153
- ["Left Leg"] = "Torso",
154
- ["Right Leg"] = "Torso",
155
- }
156
-
157
- local R15_PARENT: { [string]: string } = {
158
- LowerTorso = "HumanoidRootPart",
159
- UpperTorso = "LowerTorso",
160
- Head = "UpperTorso",
161
- LeftUpperArm = "UpperTorso",
162
- LeftLowerArm = "LeftUpperArm",
163
- LeftHand = "LeftLowerArm",
164
- RightUpperArm = "UpperTorso",
165
- RightLowerArm = "RightUpperArm",
166
- RightHand = "RightLowerArm",
167
- LeftUpperLeg = "LowerTorso",
168
- LeftLowerLeg = "LeftUpperLeg",
169
- LeftFoot = "LeftLowerLeg",
170
- RightUpperLeg = "LowerTorso",
171
- RightLowerLeg = "RightUpperLeg",
172
- RightFoot = "RightLowerLeg",
173
- }
174
-
175
- local R6_PARTS = {
176
- ["Left Arm"] = true,
177
- ["Right Arm"] = true,
178
- ["Left Leg"] = true,
179
- ["Right Leg"] = true,
180
- ["Torso"] = true,
181
- }
182
-
183
- local R15_PARTS = {
184
- UpperTorso = true,
185
- LowerTorso = true,
186
- LeftUpperArm = true,
187
- RightUpperArm = true,
188
- LeftUpperLeg = true,
189
- RightUpperLeg = true,
190
- LeftLowerArm = true,
191
- RightLowerArm = true,
192
- }
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
- local function rigOf(joints: { [string]: boolean }): (string, string?)
211
- local r6, r15 = 0, 0
212
- for joint in joints do
213
- if R6_PARTS[joint] then
214
- r6 += 1
215
- elseif R15_PARTS[joint] then
216
- r15 += 1
217
- end
218
- end
219
-
220
- local rig = "custom"
221
- if r15 > 0 and r15 >= r6 then
222
- rig = "R15"
223
- elseif r6 > 0 then
224
- rig = "R6"
225
- end
226
-
227
- --[[
228
- A tool animation is not a third rig; it is an ordinary one that only
229
- moves the arm holding the tool. Roblox's own "toolnone" moves the right
230
- arm and the torso and nothing else, which is what this recognises --
231
- useful because such an animation looks broken when read as a character
232
- animation ("it only moves two joints") and is in fact complete.
233
- ]]
234
- local arms = 0
235
- local others = 0
236
- for joint in joints do
237
- if string.find(joint, "Arm") ~= nil or string.find(joint, "Hand") ~= nil then
238
- arms += 1
239
- elseif joint ~= "HumanoidRootPart" and joint ~= "Torso" and joint ~= "UpperTorso" then
240
- others += 1
241
- end
242
- end
243
- local shape: string? = nil
244
- if arms > 0 and others == 0 then
245
- shape = "arm-only -- the shape of a tool or holding animation"
246
- end
247
-
248
- return rig, shape
249
- end
250
-
251
- --[[
252
- Downloads an animation and describes it.
253
-
254
- The summary is the point, not the dump. `duration`, which joints move at all,
255
- and whether the last keyframe matches the first are the three things that
256
- decide whether an animation is wrong, and none of them is visible from the
257
- asset id that `inspect` returns today.
258
- ]]
259
- function Anim.read(params: { [string]: any }): { [string]: any }
260
- local id = contentId(params)
261
-
262
- local ok, sequence = pcall(function()
263
- return KeyframeSequenceProvider:GetKeyframeSequenceAsync(id)
264
- end)
265
- if not ok then
266
- Dispatch.fail(
267
- "ANIMATION_UNAVAILABLE",
268
- string.format("Could not read %s: %s", id, tostring(sequence)),
269
- "The animation must be public, or owned by the account signed into Studio."
270
- )
271
- end
272
-
273
- local sequenceInstance = sequence :: KeyframeSequence
274
- local keyframes = sequenceInstance:GetKeyframes()
275
- table.sort(keyframes, function(a, b)
276
- return (a :: Keyframe).Time < (b :: Keyframe).Time
277
- end)
278
-
279
- local joints: { [string]: boolean } = {}
280
- local rows: { { [string]: any } } = {}
281
- local duration = 0
282
- local firstValue: { [string]: string } = {}
283
- local lastValue: { [string]: string } = {}
284
-
285
- for index, frame in keyframes do
286
- local keyframe = frame :: Keyframe
287
- duration = math.max(duration, keyframe.Time)
288
-
289
- local poses: { { [string]: any } } = {}
290
- flatten(keyframe, "", poses)
291
- for _, pose in poses do
292
- --[[
293
- First and last value per joint, for the loop check below.
294
-
295
- Recorded as they are walked because keyframes are already in time
296
- order here, so "last seen" is the final value by the time the walk
297
- ends -- no second pass and no sorting.
298
- ]]
299
- --[[
300
- Keyed by leaf name, never by the path it was found under.
301
-
302
- The same joint does not keep the same path across an animation.
303
- Measured on Roblox's own "toolnone": the right arm is nested under
304
- `HumanoidRootPart/Torso/Right Arm` in the first keyframe and under
305
- `Torso/Right Arm` in every keyframe after it -- same joint, same
306
- rig, two different strings. Keying on the path therefore split one
307
- joint into two, and each half looked like it had no matching end
308
- value, so a perfectly clean loop was reported as drifting on two
309
- joints. The values themselves were identical the whole time.
310
- ]]
311
- local path = tostring(pose.joint)
312
- local key = string.match(path, "([^/]+)$") or path
313
- if firstValue[key] == nil then
314
- firstValue[key] = tostring(pose.cframe)
315
- end
316
- lastValue[key] = tostring(pose.cframe)
317
- end
318
- for _, pose in poses do
319
- --[[
320
- The leaf name, not the path it came down.
321
-
322
- A face-rigged R15 has 109 joints and their full paths run six
323
- segments deep, so the honest list of them is several thousand
324
- characters of "HumanoidRootPart/LowerTorso/UpperTorso/..." repeated
325
- with a different ending each time. That is the whole reply, for a
326
- summary line nobody can read. The paths are still on each pose,
327
- where the nesting is the point; here the question is only which
328
- joints move.
329
- ]]
330
- local path = tostring(pose.joint)
331
- joints[string.match(path, "([^/]+)$") or path] = true
332
- end
333
-
334
- table.insert(rows, {
335
- index = index,
336
- time = math.round(keyframe.Time * 1000) / 1000,
337
- name = if keyframe.Name ~= "" then keyframe.Name else nil,
338
- poseCount = #poses,
339
- -- Poses only while the listing is small enough to be worth reading.
340
- poses = if index <= DETAIL_LIMIT then poses else nil,
341
- })
342
- end
343
-
344
- local rig, shape = rigOf(joints)
345
-
346
- local moved: { string } = {}
347
- for joint in joints do
348
- table.insert(moved, joint)
349
- end
350
- table.sort(moved)
351
- local count = #moved
352
-
353
- --[[
354
- Capped, with the count kept separate.
355
-
356
- `jointCount` is the number that answers "does this animate the whole body
357
- or one arm", and it stays exact. The names are a sample past this point,
358
- because a rigged face contributes eighty of them and nobody reading a
359
- summary needs to see `L_eyeD` spelled out.
360
- ]]
361
- local JOINT_SAMPLE = 24
362
- local sampled = #moved > JOINT_SAMPLE
363
- if sampled then
364
- local short: { string } = {}
365
- table.move(moved, 1, JOINT_SAMPLE, 1, short)
366
- moved = short
367
- end
368
-
369
- --[[
370
- Whether the animation ends where it began.
371
-
372
- This is the most common fault in a looping animation and it is invisible
373
- in the editor until you watch the loop point twice.
374
-
375
- Compared on the VALUES each joint ends at against the values it started
376
- at. An earlier version compared how many poses the first and last
377
- keyframes carried, and that was wrong in a way worth recording: a
378
- keyframe that omits a joint is not moving it back to anywhere, it is
379
- leaving it where the previous keyframe put it. Roblox's own "toolnone"
380
- animation poses three joints at the start and two afterwards, and the
381
- count test called it broken -- a false alarm on the platform's own asset,
382
- which is about the clearest signal that the test was measuring the wrong
383
- thing.
384
-
385
- String comparison of the serialised CFrames, which is exact here: both
386
- values come from the same downloaded sequence, so there is no rounding
387
- between them to produce a phantom difference.
388
- ]]
389
- local drifted: { string } = {}
390
- for joint, startValue in firstValue do
391
- if lastValue[joint] ~= startValue then
392
- if #drifted < 8 then
393
- table.insert(drifted, joint)
394
- end
395
- end
396
- end
397
- table.sort(drifted)
398
- local loops: boolean? = if #rows >= 2 then #drifted == 0 else nil
399
-
400
- return {
401
- assetId = id,
402
- name = if sequenceInstance.Name ~= "" then sequenceInstance.Name else nil,
403
- priority = tostring(sequenceInstance.Priority):gsub("Enum%.AnimationPriority%.", ""),
404
- looping = sequenceInstance.Loop,
405
- duration = math.round(duration * 1000) / 1000,
406
- keyframeCount = #keyframes,
407
- joints = moved,
408
- jointCount = count,
409
- jointsSampled = sampled,
410
- keyframes = rows,
411
- endsWhereItStarted = loops,
412
- -- Named, not just counted: "the right arm does not return" is actionable
413
- -- where "it does not loop cleanly" sends someone to look at all of it.
414
- driftingJoints = if #drifted > 0 then drifted else nil,
415
- rig = rig,
416
- rigNote = shape,
417
- truncated = #keyframes > DETAIL_LIMIT,
418
- }
419
- end
420
-
421
- --[[
422
- The joint tree to nest poses against.
423
-
424
- A named rig wins, because it is the truth: a custom rig, a tool, a door, a
425
- Blender import with its own bone names -- none of those are in the tables
426
- above, and all of them are ordinary things to animate. The rig's own Motor6Ds
427
- say exactly which part hangs off which.
428
-
429
- Without one, the pose names pick the map. Every R15 name is unique to R15, so
430
- one of them appearing settles it; "Head" alone is ambiguous and falls to R6,
431
- which is the older and likelier default for a hand-written animation.
432
- ]]
433
- local function parentMap(params: { [string]: any }, joints: { [string]: boolean }): ({ [string]: string }, string)
434
- if typeof(params.rig) == "string" and params.rig ~= "" then
435
- local model = Paths.resolve(params.rig)
436
- local map: { [string]: string } = {}
437
- local found = 0
438
- for _, descendant in model:GetDescendants() do
439
- if descendant:IsA("Motor6D") then
440
- local zero, one = descendant.Part0, descendant.Part1
441
- if zero ~= nil and one ~= nil then
442
- map[one.Name] = zero.Name
443
- found += 1
444
- end
445
- end
446
- end
447
- if found == 0 then
448
- Dispatch.fail(
449
- "NO_RIG",
450
- string.format("%s has no Motor6D joints, so there is no hierarchy to build against.", params.rig),
451
- "Point `rig` at the model whose parts the poses name."
452
- )
453
- end
454
- return map, "rig"
455
- end
456
-
457
- for joint in joints do
458
- if R15_PARENT[joint] ~= nil and R6_PARENT[joint] == nil then
459
- return R15_PARENT, "R15"
460
- end
461
- end
462
- return R6_PARENT, "R6"
463
- end
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
- function Anim.build(params: { [string]: any }): { [string]: any }
481
- local frames = params.keyframes
482
- if typeof(frames) ~= "table" or #(frames :: { any }) == 0 then
483
- Dispatch.fail(
484
- "BAD_PARAMS",
485
- "build needs a non-empty `keyframes` array.",
486
- 'Each entry is { time = 0.5, poses = { ["Right Arm"] = "0, 1, 0 | 0, 45, 0" } }.'
487
- )
488
- end
489
-
490
- local sequence = Instance.new("KeyframeSequence")
491
- sequence.Name = if typeof(params.name) == "string" and params.name ~= "" then params.name else "MCPAnimation"
492
- sequence.Loop = params.loop == true
493
- if typeof(params.priority) == "string" and params.priority ~= "" then
494
- local okPriority = pcall(function()
495
- sequence.Priority = (Enum :: any).AnimationPriority[params.priority]
496
- end)
497
- if not okPriority then
498
- sequence:Destroy()
499
- Dispatch.fail(
500
- "BAD_PARAMS",
501
- string.format("%q is not an AnimationPriority.", tostring(params.priority)),
502
- 'Use "Idle", "Movement", "Action" or "Core".'
503
- )
504
- end
505
- end
506
-
507
- local rootName = if typeof(params.root) == "string" and params.root ~= "" then params.root else "HumanoidRootPart"
508
- local count = 0
509
-
510
- -- Every joint the caller names, gathered before anything is built, because
511
- -- the rig guess reads the whole set rather than one keyframe's worth.
512
- local joints: { [string]: boolean } = {}
513
- for _, entry in frames :: { any } do
514
- if typeof(entry) == "table" and typeof((entry :: any).poses) == "table" then
515
- for joint in (entry :: any).poses :: { [string]: any } do
516
- joints[tostring(joint)] = true
517
- end
518
- end
519
- end
520
-
521
- local parentOf, hierarchy = parentMap(params, joints)
522
-
523
- local unmatched: { string } = {}
524
- for joint in joints do
525
- if joint ~= rootName and parentOf[joint] == nil then
526
- table.insert(unmatched, joint)
527
- end
528
- end
529
- table.sort(unmatched)
530
-
531
- for position, entry in frames :: { any } do
532
- if typeof(entry) ~= "table" then
533
- sequence:Destroy()
534
- Dispatch.fail("BAD_PARAMS", string.format("keyframe %d is not an object.", position))
535
- end
536
- local frame = entry :: { [string]: any }
537
-
538
- local keyframe = Instance.new("Keyframe")
539
- keyframe.Time = tonumber(frame.time) or 0
540
- if typeof(frame.name) == "string" and frame.name ~= "" then
541
- keyframe.Name = frame.name
542
- end
543
-
544
- local root = Instance.new("Pose")
545
- root.Name = rootName
546
- root.Weight = 1
547
- root.Parent = keyframe
548
-
549
- --[[
550
- Poses placed where the rig says they belong, creating whatever links
551
- the chain needs along the way.
552
-
553
- A pose for "Right Arm" is only read if its parent pose is "Torso",
554
- because that is the joint that moves it -- so animating a hand means
555
- the whole arm appears in the tree whether or not the caller mentioned
556
- it. Those filler poses carry Weight 0: they exist to hold the
557
- hierarchy together, and a weight of 1 would pin the joint they name to
558
- its rest pose, quietly cancelling any movement the caller did ask for.
559
- ]]
560
- local placed: { [string]: Pose } = {}
561
- local function poseFor(joint: string): Pose
562
- if joint == rootName then
563
- return root
564
- end
565
- local existing = placed[joint]
566
- if existing ~= nil then
567
- return existing
568
- end
569
- local pose = Instance.new("Pose")
570
- pose.Name = joint
571
- pose.CFrame = CFrame.new()
572
- pose.Weight = 0
573
- local above = parentOf[joint]
574
- -- An unknown joint hangs off the root, which is what this did for
575
- -- every joint before. It is reported rather than assumed correct.
576
- pose.Parent = if above ~= nil and above ~= joint then poseFor(above) else root
577
- placed[joint] = pose
578
- return pose
579
- end
580
-
581
- if typeof(frame.poses) == "table" then
582
- for joint, value in frame.poses :: { [string]: any } do
583
- local okValue, cframe, why = Serialize.parse(tostring(value), "CFrame")
584
- if not okValue then
585
- sequence:Destroy()
586
- Dispatch.fail(
587
- "BAD_PARAMS",
588
- string.format("keyframe %d, joint %q: %s", position, tostring(joint), tostring(why)),
589
- 'A pose is written like a CFrame property: "0, 1, 0" or "0, 1, 0 | 0, 45, 0".'
590
- )
591
- end
592
- local name = tostring(joint)
593
- local pose = poseFor(name)
594
- pose.CFrame = cframe :: CFrame
595
- pose.Weight = 1
596
- count += 1
597
- end
598
- end
599
-
600
- keyframe.Parent = sequence
601
- end
602
-
603
- local ok, id = pcall(function()
604
- return KeyframeSequenceProvider:RegisterKeyframeSequence(sequence)
605
- end)
606
- -- Destroyed either way: registration copies what it needs, and the instance
607
- -- was never in the data model to begin with.
608
- sequence:Destroy()
609
-
610
- if not ok then
611
- Dispatch.fail("ANIMATION_FAILED", string.format("Could not register the sequence: %s", tostring(id)))
612
- end
613
-
614
- return {
615
- animationId = tostring(id),
616
- keyframeCount = #(frames :: { any }),
617
- poseCount = count,
618
- hierarchy = hierarchy,
619
- -- Named, because a joint the rig does not have produces an animation that
620
- -- plays perfectly and moves nothing -- the one failure here with no
621
- -- symptom at all.
622
- unmatchedJoints = if #unmatched > 0 then unmatched else nil,
623
- --[[
624
- Used exactly as it comes back, with nothing added in front of it.
625
-
626
- `RegisterKeyframeSequence` returns a bare hash rather than anything
627
- that looks like a content id, which invites a caller to "fix" it into
628
- `rbxasset://<hash>`. Measured: the bare string sets on
629
- `Animation.AnimationId` and reads back through
630
- `GetKeyframeSequenceAsync`; the prefixed one fails both. So the shape
631
- is correct and the instinct to correct it is the bug.
632
- ]]
633
- note = "Use this id verbatim as an Animation's AnimationId -- do NOT put "
634
- .. "rbxassetid:// or rbxasset:// in front of it, which stops it working. "
635
- .. "It plays in this Studio session only: not uploaded, and gone on restart.",
636
- }
637
- end
638
-
639
- --[[
640
- Puts a rig back the way it was before anything was played on it.
641
-
642
- Four steps, and every one of them is load-bearing -- this took three wrong
643
- versions to get right, so the reasoning is written down rather than left to
644
- be rediscovered.
645
-
646
- 1. Speed back to 1 before stopping. `preview` freezes a pose by setting speed
647
- to 0, and a fade of length 0 at speed 0 never finishes.
648
- 2. Step, so the stop is actually processed. Nothing steps an Animator in edit
649
- mode, so `Stop` on its own leaves the track in
650
- `GetPlayingAnimationTracks` and leaves the rig bent, while the call
651
- reports it stopped.
652
- 3. Clear every `Motor6D.Transform` by hand. Removing the track does NOT clear
653
- the transforms it wrote -- with no track left there is nothing to write
654
- anything, so the last animated pose simply stays. Measured: track count 0,
655
- shoulder transform still (0, 0, 0.25).
656
- 4. Step once more. Writing `Transform` changes nothing by itself in edit
657
- mode; the step is what makes the engine rebuild the part CFrames from the
658
- joints, and that is the frame the rig comes back on.
659
-
660
- The tempting shortcut -- compute each joint's rest CFrame and write it to the
661
- part -- is worse than doing nothing. Setting `CFrame` on a part jointed to
662
- others moves the WHOLE assembly, so placing the head shifted the body,
663
- placing the arm shifted it again, and the rig ended up floating and rotated
664
- with its anchored root dragged along. Measured: an R6 rig came out at
665
- (0.08, 3.86, 0.37) rotated 67 degrees after a "restore".
666
- ]]
667
- local function restPose(model: Instance, animator: Animator)
668
- for _, track in animator:GetPlayingAnimationTracks() do
669
- track:AdjustSpeed(1)
670
- track:Stop(0)
671
- end
672
-
673
- --[[
674
- Stepped until the stop has actually finished, which takes longer than it
675
- sounds like it should.
676
-
677
- `Stop(0)` asks for no fade and does not get one: the track's weight comes
678
- down over frames regardless, and it stays in
679
- `GetPlayingAnimationTracks` the whole time -- with `IsPlaying` already
680
- false, so list membership is not a measure of anything. Measured on an R6
681
- rig: eleven steps, weight falling 1 -> 0.61 -> 0, and the track vanished
682
- on the eleventh.
683
-
684
- Guessing the number is how this went wrong twice. One step left the rig
685
- bent; six steps was worse than one, because the fade was still running
686
- when the transforms below were cleared and the next step wrote the
687
- half-faded pose straight back over them. So the loop watches for the
688
- answer, and the cap is a second of simulated time -- far past any fade,
689
- and still a bound rather than a spin.
690
- ]]
691
- for _ = 1, 60 do
692
- if #animator:GetPlayingAnimationTracks() == 0 then
693
- break
694
- end
695
- animator:StepAnimations(1 / 60)
696
- end
697
-
698
- for _, descendant in model:GetDescendants() do
699
- if descendant:IsA("Motor6D") then
700
- descendant.Transform = CFrame.new()
701
- end
702
- end
703
- animator:StepAnimations(1 / 60)
704
- end
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
- function Anim.preview(params: { [string]: any }): { [string]: any }
726
- local rigPath = params.rig
727
- if typeof(rigPath) ~= "string" or rigPath == "" then
728
- Dispatch.fail(
729
- "BAD_PARAMS",
730
- "preview needs a `rig` -- the path of a model with a Humanoid or an AnimationController.",
731
- 'Find one with `find selector="Humanoid"`.'
732
- )
733
- end
734
-
735
- local model = Paths.resolve(rigPath)
736
- local animator: Animator? = nil
737
-
738
- --[[
739
- The Animator, wherever it lives.
740
-
741
- A character rig keeps it under the Humanoid; a non-character rig keeps it
742
- under an AnimationController. Both are ordinary in a place, and a tool
743
- that only knew about one of them would refuse half the rigs it was
744
- pointed at. Created if missing, because a rig imported from Blender often
745
- has the controller and not the animator, and adding one is what Studio
746
- does too.
747
- ]]
748
- local host = model:FindFirstChildWhichIsA("Humanoid") or model:FindFirstChildWhichIsA("AnimationController")
749
- if host == nil then
750
- Dispatch.fail(
751
- "NO_RIG",
752
- string.format("%s has no Humanoid or AnimationController, so nothing can play an animation on it.", rigPath)
753
- )
754
- end
755
- animator = (host :: Instance):FindFirstChildWhichIsA("Animator")
756
- if animator == nil then
757
- local created = Instance.new("Animator")
758
- created.Parent = host
759
- animator = created
760
- end
761
-
762
- if params.op == "stop" then
763
- local stopped = 0
764
- for _, track in (animator :: Animator):GetPlayingAnimationTracks() do
765
- track:Stop(0)
766
- stopped += 1
767
- end
768
- restPose(model, animator :: Animator)
769
- local left = #(animator :: Animator):GetPlayingAnimationTracks()
770
- return {
771
- rig = Paths.of(model),
772
- stopped = stopped,
773
- stillPlaying = if left > 0 then left else nil,
774
- note = if left > 0
775
- then "Some tracks are still playing, so the rig may still be posed."
776
- else "The rig is back in its rest pose.",
777
- }
778
- end
779
-
780
- local id = contentId(params)
781
- local animation = Instance.new("Animation")
782
- animation.AnimationId = id
783
-
784
- local okLoad, track = pcall(function()
785
- return (animator :: Animator):LoadAnimation(animation)
786
- end)
787
- if not okLoad then
788
- animation:Destroy()
789
- Dispatch.fail("ANIMATION_FAILED", string.format("Could not load %s: %s", id, tostring(track)))
790
- end
791
-
792
- local clip = track :: AnimationTrack
793
-
794
- --[[
795
- Waited for, because a freshly loaded track has no length yet.
796
-
797
- `LoadAnimation` returns before the asset has been fetched, and a track
798
- with `Length == 0` cannot be seeked -- an unwaited preview silently shows
799
- frame zero of everything. Measured: the length arrives within a frame or
800
- two of a warm cache and takes noticeably longer on a cold one.
801
- ]]
802
- local waited = 0
803
- while clip.Length == 0 and waited < 5 do
804
- waited += task.wait()
805
- end
806
- if clip.Length == 0 then
807
- animation:Destroy()
808
- Dispatch.fail(
809
- "ANIMATION_TIMEOUT",
810
- string.format("%s did not load within 5s.", id),
811
- "The animation must be public, or owned by the account signed into Studio."
812
- )
813
- end
814
-
815
- --[[
816
- Everything else on this rig stops first, or two animations blend and the
817
- picture is of neither.
818
-
819
- `Stop` leaves the track in the list for a moment, so a second preview used
820
- to show two entries for the same animation. Destroying the track object
821
- removes it outright, which keeps a repeated preview from accumulating one
822
- dead track per call.
823
- ]]
824
- for _, other in (animator :: Animator):GetPlayingAnimationTracks() do
825
- if other ~= clip then
826
- other:Stop(0)
827
- other:Destroy()
828
- end
829
- end
830
-
831
- local at = math.clamp(tonumber(params.at) or 0, 0, clip.Length)
832
-
833
- --[[
834
- Stepped by hand, because nothing steps it in edit mode.
835
-
836
- This is the whole reason a first attempt at this reported perfect success
837
- and moved nothing. In a running game the engine advances every Animator
838
- each frame; in an edit session it advances none of them, so a track can be
839
- playing, at the right TimePosition, and contributing exactly nothing
840
- forever. Measured: `WeightCurrent` stayed 0 through any amount of
841
- `task.wait`, and every Motor6D transform stayed identity.
842
-
843
- `Animator:StepAnimations(delta)` is what Studio's own animation editor
844
- uses, and it is what actually moves the rig. A few steps at a sixtieth of
845
- a second let the weight blend reach 1; then the time is set and a
846
- zero-length step applies that exact pose without advancing it.
847
- ]]
848
- clip.Priority = Enum.AnimationPriority.Action
849
- clip:Play(0, 1, 1)
850
-
851
- local function step(delta: number)
852
- pcall(function()
853
- (animator :: any):StepAnimations(delta)
854
- end)
855
- end
856
-
857
- for _ = 1, 5 do
858
- step(1 / 60)
859
- end
860
-
861
- if params.hold ~= false then
862
- clip:AdjustSpeed(0)
863
- end
864
- clip.TimePosition = at
865
- -- Zero advances nothing and still applies the pose at the time just set.
866
- step(0)
867
-
868
- local weight = clip.WeightCurrent
869
- animation:Destroy()
870
-
871
- return {
872
- rig = Paths.of(model),
873
- animationId = id,
874
- length = math.round(clip.Length * 1000) / 1000,
875
- at = math.round(at * 1000) / 1000,
876
- holding = params.hold ~= false,
877
- -- Reported because a weight of zero means the rig is NOT posed, however
878
- -- healthy everything else in this reply looks.
879
- weight = math.round(weight * 100) / 100,
880
- note = if weight > 0
881
- then "The rig is posed at this moment now -- take a `screenshot` to see it. "
882
- .. 'Call again with a different `at` to compare, or op="stop" to clear it.'
883
- else "WARNING: the animation loaded but its weight is 0, so the rig is NOT "
884
- .. "posed. Something else is animating this rig, or it has no Motor6D "
885
- .. "joints for the animation to drive.",
886
- }
887
- end
888
-
889
- function Anim.register()
890
- Dispatch.registerAll("anim", {
891
- read = Anim.read,
892
- preview = Anim.preview,
893
- build = Anim.build,
894
- })
895
- end
896
-
897
- return Anim