@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.7

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 (75) hide show
  1. package/README.md +28 -2
  2. package/dist/bridge/console.js +182 -0
  3. package/dist/bridge/console.js.map +1 -1
  4. package/dist/index.js +8 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/cloudassets.js +233 -0
  7. package/dist/lib/cloudassets.js.map +1 -0
  8. package/dist/lib/credentials.js +180 -0
  9. package/dist/lib/credentials.js.map +1 -0
  10. package/dist/lib/livedata.js +325 -0
  11. package/dist/lib/livedata.js.map +1 -0
  12. package/dist/lib/liveluau.js +83 -0
  13. package/dist/lib/liveluau.js.map +1 -0
  14. package/dist/lib/liveops.js +358 -0
  15. package/dist/lib/liveops.js.map +1 -0
  16. package/dist/lib/opencloud.js +235 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/anim.js +159 -0
  19. package/dist/tools/anim.js.map +1 -0
  20. package/dist/tools/audio.js +96 -0
  21. package/dist/tools/audio.js.map +1 -0
  22. package/dist/tools/character.js +95 -5
  23. package/dist/tools/character.js.map +1 -1
  24. package/dist/tools/data.js +292 -0
  25. package/dist/tools/data.js.map +1 -0
  26. package/dist/tools/device.js +77 -7
  27. package/dist/tools/device.js.map +1 -1
  28. package/dist/tools/discover.js +80 -4
  29. package/dist/tools/discover.js.map +1 -1
  30. package/dist/tools/exec.js +96 -2
  31. package/dist/tools/exec.js.map +1 -1
  32. package/dist/tools/input.js +35 -9
  33. package/dist/tools/input.js.map +1 -1
  34. package/dist/tools/perf.js +74 -7
  35. package/dist/tools/perf.js.map +1 -1
  36. package/dist/tools/scripts.js +162 -6
  37. package/dist/tools/scripts.js.map +1 -1
  38. package/dist/tools/spatial.js +135 -0
  39. package/dist/tools/spatial.js.map +1 -0
  40. package/dist/tools/universe.js +177 -0
  41. package/dist/tools/universe.js.map +1 -0
  42. package/dist/tools/upload.js +294 -0
  43. package/dist/tools/upload.js.map +1 -0
  44. package/dist/tools/world.js +675 -51
  45. package/dist/tools/world.js.map +1 -1
  46. package/package.json +2 -2
  47. package/plugin/src/Commands.luau +31 -7
  48. package/plugin/src/Config.luau +65 -65
  49. package/plugin/src/Console.luau +1909 -1843
  50. package/plugin/src/Emulation.luau +172 -0
  51. package/plugin/src/Phrase.luau +816 -618
  52. package/plugin/src/Png.luau +8 -4
  53. package/plugin/src/Prompt.luau +965 -961
  54. package/plugin/src/Secret.luau +86 -0
  55. package/plugin/src/Serialize.luau +440 -8
  56. package/plugin/src/Undo.luau +94 -6
  57. package/plugin/src/handlers/Anim.luau +897 -0
  58. package/plugin/src/handlers/Assets.luau +286 -2
  59. package/plugin/src/handlers/Audio.luau +411 -0
  60. package/plugin/src/handlers/Capture.luau +155 -20
  61. package/plugin/src/handlers/Character.luau +823 -361
  62. package/plugin/src/handlers/Data.luau +539 -0
  63. package/plugin/src/handlers/Device.luau +394 -139
  64. package/plugin/src/handlers/Discover.luau +685 -363
  65. package/plugin/src/handlers/Geometry.luau +722 -450
  66. package/plugin/src/handlers/Instances.luau +84 -4
  67. package/plugin/src/handlers/Perf.luau +227 -0
  68. package/plugin/src/handlers/Scripts.luau +673 -539
  69. package/plugin/src/handlers/Session.luau +3 -0
  70. package/plugin/src/handlers/Spatial.luau +334 -0
  71. package/plugin/src/handlers/Viewport.luau +268 -0
  72. package/plugin/src/handlers/World.luau +89 -15
  73. package/plugin/src/init.server.luau +9 -1
  74. package/scripts/build-plugin.mjs +20 -0
  75. package/scripts/check-plugin.mjs +171 -124
@@ -0,0 +1,86 @@
1
+ --!strict
2
+ --[[
3
+ Keeps a typed secret out of everything that remembers.
4
+
5
+ The panel is built to remember what you type. The line is echoed into the log
6
+ under "you", it goes into the up-arrow history, the log can be copied to the
7
+ clipboard with `copy`, and the whole widget ends up in screenshots -- which
8
+ this very server takes on request. Every one of those is correct behaviour
9
+ for `theme dark` and a disaster for an API key: a credential that can publish
10
+ assets under the user's name would sit in a scrollback that gets pasted into
11
+ a bug report.
12
+
13
+ So the one command that carries a secret is masked at every point where a
14
+ line is kept, and that masking lives here rather than in `Commands` because
15
+ two unrelated files -- the echo and the history -- both have to do it, and a
16
+ secret protected in one of two places is not protected.
17
+
18
+ Masking, not dropping. `cloud key ****` in the log still tells the user that
19
+ the command ran; a line that vanished would read as the panel ignoring them,
20
+ and the next thing a person does about that is type it again.
21
+ ]]
22
+
23
+ local Secret = {}
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
+ --[[
35
+ Commands whose arguments are secret, and from which word onwards.
36
+
37
+ Keyed by "command subcommand" so `cloud key <secret>` is covered and `cloud
38
+ test` -- which carries nothing -- is not masked into uselessness.
39
+
40
+ ONLY the key. The user, group, universe and place ids were masked here too
41
+ at first, which was theatre: they are public -- a user id is in the profile
42
+ URL and a universe id is on the game's dashboard page -- and the reply that
43
+ confirms each one prints it back in full anyway, so the dots hid a value
44
+ from the same log line that then showed it. Masking something that is about
45
+ to be displayed teaches a reader that the dots mean nothing, which is
46
+ exactly the wrong lesson for the one line where they do.
47
+ ]]
48
+ local SECRET_FROM: { [string]: number } = {
49
+ ["cloud key"] = 3,
50
+ }
51
+
52
+ --[[
53
+ Returns the line as it is safe to display, and whether anything was hidden.
54
+
55
+ The caller gets the flag because "this was masked" is worth saying once, next
56
+ to the echoed line -- otherwise a user who sees dots where they typed a key
57
+ cannot tell whether the panel hid it or mangled it.
58
+ ]]
59
+ function Secret.mask(line: string): (string, boolean)
60
+ local words: { string } = {}
61
+ for word in string.gmatch(line, "%S+") do
62
+ table.insert(words, word)
63
+ end
64
+ if #words < 2 then
65
+ return line, false
66
+ end
67
+
68
+ local from = SECRET_FROM[string.lower(words[1]) .. " " .. string.lower(words[2])]
69
+ if from == nil or #words < from then
70
+ return line, false
71
+ end
72
+
73
+ --[[
74
+ One fixed marker for everything from `from` on, not one per character
75
+ and not one per word.
76
+
77
+ Length is information: eight dots against thirty says which of two things
78
+ was typed, and for a key it narrows a guess. Every masked value looks
79
+ identical, whatever was actually there.
80
+ ]]
81
+ local masked = table.move(words, 1, from - 1, 1, {} :: { string })
82
+ table.insert(masked, "••••••••")
83
+ return table.concat(masked, " "), true
84
+ end
85
+
86
+ return Secret
@@ -13,8 +13,21 @@
13
13
  straight back without the agent reformatting it.
14
14
  ]]
15
15
 
16
+ local Paths = require(script.Parent.Paths)
17
+
16
18
  local Serialize = {}
17
19
 
20
+ --[[
21
+ Marks a failure that means "the instance is not there" rather than "this
22
+ value is wrong".
23
+
24
+ The create handler retries only these, once the whole batch is attached, so
25
+ a reference to a sibling made in the same call resolves. Anything else is a
26
+ real mistake and is reported straight away.
27
+ ]]
28
+ local UNRESOLVED_PREFIX = "[unresolved] "
29
+ Serialize.UNRESOLVED_PREFIX = UNRESOLVED_PREFIX
30
+
18
31
  -- Floats are rounded before display: Studio reports positions like
19
32
  -- 12.000000476837158, which costs tokens and means nothing to the caller.
20
33
  local PRECISION = 4
@@ -87,11 +100,54 @@ function Serialize.value(value: any): any
87
100
  if kind == "UDim" then
88
101
  return string.format("{%s, %s}", num(value.Scale), num(value.Offset))
89
102
  end
103
+ if kind == "Font" then
104
+ --[[
105
+ Written so it can be read back, which `tostring` is not.
106
+
107
+ A Font stringifies as `Font { Family = rbxasset://..., Weight = Bold,
108
+ Style = Normal }`, which nothing here can parse. The family, weight
109
+ and style are all there is to one, so they are written plainly and
110
+ separated the way a composite value is elsewhere in this module.
111
+ ]]
112
+ return string.format(
113
+ "%s | %s | %s",
114
+ value.Family,
115
+ value.Weight.Name,
116
+ value.Style.Name
117
+ )
118
+ end
90
119
  if kind == "EnumItem" then
91
120
  return string.format("Enum.%s.%s", tostring(value.EnumType), value.Name)
92
121
  end
122
+ if kind == "Content" then
123
+ --[[
124
+ Written as the bare URI so it can be read back.
125
+
126
+ `tostring` on a Content gives `Content{SourceType=Uri,
127
+ Uri=rbxassetid://123}`, which `parse` cannot take. A Content that
128
+ holds an Object or no source at all has no URI to give, so those are
129
+ named rather than rendered as an empty string that would read as
130
+ "cleared".
131
+ ]]
132
+ if value.Uri then
133
+ return value.Uri
134
+ end
135
+ if value.Object then
136
+ return Paths.of(value.Object)
137
+ end
138
+ return ""
139
+ end
93
140
  if kind == "Instance" then
94
- return value:GetFullName()
141
+ --[[
142
+ Formatted as an address, not as a display name.
143
+
144
+ `GetFullName` writes "StarterPack.Pistol.Handle" for both of two
145
+ Tools named Pistol, so reading a Motor6D's Part1 and writing it back
146
+ pointed at the wrong one -- silently, since both paths resolve.
147
+ `Paths.of` adds the [n] that tells them apart, which is what makes a
148
+ value read here safe to write straight back.
149
+ ]]
150
+ return Paths.of(value)
95
151
  end
96
152
  if kind == "NumberRange" then
97
153
  return string.format("%s..%s", num(value.Min), num(value.Max))
@@ -176,6 +232,51 @@ local function scanTokens(text: string): { number }
176
232
  return numbers
177
233
  end
178
234
 
235
+ --[[
236
+ ColorSequence and NumberSequence.
237
+
238
+ `Serialize.value` has always WRITTEN these -- "0=1, 0, 0 1=0, 0, 1" for a
239
+ red-to-blue gradient -- and `parse` had no branch for either, so every one of
240
+ them could be read and none could be written back. That is not a corner:
241
+ ColorSequence and NumberSequence are what ParticleEmitter, Beam, Trail and
242
+ UIGradient are made of, so recolouring any effect fell out of `modify`
243
+ entirely and needed `execute_luau`. Found by auditing the round trip rather
244
+ than by hitting it.
245
+
246
+ Keypoints are split on the "<time>=" markers rather than on whitespace,
247
+ because the VALUE of a ColorSequence keypoint contains both commas and
248
+ spaces of its own and nothing else can tell the two apart.
249
+
250
+ A bare value is also accepted and means a constant sequence: `Color3` text
251
+ for a ColorSequence, one number for a NumberSequence. People write
252
+ `Color = "1, 0, 0"` meaning solid red, and refusing that to insist on
253
+ "0=1, 0, 0 1=1, 0, 0" would be pedantry.
254
+ ]]
255
+ local function splitKeypoints(text: string): { { time: number, value: string } }
256
+ local marks: { { at: number, finish: number, time: number } } = {}
257
+ local index = 1
258
+ while true do
259
+ local at, finish, captured = string.find(text, "([%d%.]+)%s*=", index)
260
+ if not at then
261
+ break
262
+ end
263
+ local time = tonumber(captured)
264
+ if time then
265
+ table.insert(marks, { at = at, finish = finish, time = time })
266
+ end
267
+ index = finish + 1
268
+ end
269
+
270
+ local out: { { time: number, value: string } } = {}
271
+ for position, mark in marks do
272
+ local stop = if marks[position + 1] then marks[position + 1].at - 1 else #text
273
+ local value = string.match(string.sub(text, mark.finish + 1, stop), "^%s*(.-)%s*$") or ""
274
+ table.insert(out, { time = mark.time, value = value })
275
+ end
276
+ return out
277
+ end
278
+
279
+
179
280
  --[[
180
281
  Parses a serialized string back into a Roblox value, given the target type
181
282
  from the API dump. Returns ok=false with a reason the agent can act on
@@ -194,9 +295,48 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
194
295
  return true, text, nil
195
296
  end
196
297
 
197
- if valueType == "string" or valueType == "Content" or valueType == "ProtectedString" then
298
+ if valueType == "string" or valueType == "ProtectedString" then
198
299
  return true, tostring(text), nil
199
300
  end
301
+
302
+ --[[
303
+ Asset references come in two shapes with almost the same name.
304
+
305
+ `ContentId` is the old one and is still just a string: `MeshPart.MeshId`,
306
+ `Decal.Texture`, `SurfaceAppearance.ColorMap`. It had no branch here at
307
+ all, so every one of them failed with `no conversion known for type
308
+ "ContentId"` -- which is how a SurfaceAppearance could be created but
309
+ never given a texture.
310
+
311
+ `Content` is the new datatype and is NOT a string. Assigning
312
+ `"rbxassetid://123"` to `AudioPlayer.AudioContent` or
313
+ `ImageLabel.ImageContent` throws; it wants `Content.fromUri`. This branch
314
+ used to lump it in with `string` above, so those writes reported a parse
315
+ success and then failed at assignment with a message about a type the
316
+ caller never mentioned.
317
+
318
+ An empty string means "nothing", the same as it does for an instance
319
+ reference: `Content.none` is the engine's own name for that.
320
+ ]]
321
+ if valueType == "ContentId" then
322
+ return true, tostring(text), nil
323
+ end
324
+ if valueType == "Content" then
325
+ if typeof(text) == "Content" then
326
+ return true, text, nil
327
+ end
328
+ local uri = tostring(text)
329
+ if uri == "" then
330
+ return true, Content.none, nil
331
+ end
332
+ local okContent, content = pcall(function()
333
+ return Content.fromUri(uri)
334
+ end)
335
+ if okContent then
336
+ return true, content, nil
337
+ end
338
+ return false, nil, string.format('"%s" is not a content URI', uri)
339
+ end
200
340
  if valueType == "bool" then
201
341
  if kind == "boolean" then
202
342
  return true, text, nil
@@ -300,16 +440,232 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
300
440
  end
301
441
  return false, nil, 'expected "pos x, y, z" or "pos x, y, z | rot rx, ry, rz"'
302
442
  end
443
+ --[[
444
+ NumberRange, which the shared tokenizer cannot see.
445
+
446
+ `Serialize.value` writes one as "1..3", but `scanTokens` splits on
447
+ commas, whitespace and brackets -- never on "..", so "1..3" arrives as a
448
+ single token, `tonumber` returns nil, and the parse failed with `expected
449
+ "min..max"` while being handed exactly that. A value this module printed
450
+ could not be fed back to it, which is the one promise the whole file
451
+ makes. Caught in QA on ParticleEmitter.Lifetime.
452
+
453
+ Parsed here directly. A single number is accepted too, because
454
+ `NumberRange.new(2)` is a real range and people write it that way.
455
+ ]]
456
+ if valueType == "ColorSequence" then
457
+ if kind ~= "string" then
458
+ return false, nil, "expected text like \"0=1, 0, 0 1=0, 0, 1\""
459
+ end
460
+ local points = splitKeypoints(text)
461
+ if #points == 0 then
462
+ -- A bare colour: a sequence that is that colour the whole way.
463
+ local okColour, colour = Serialize.parse(text, "Color3")
464
+ if okColour and typeof(colour) == "Color3" then
465
+ return true, ColorSequence.new(colour :: Color3), nil
466
+ end
467
+ return false, nil, "expected \"0=1, 0, 0 1=0, 0, 1\", or one colour for a solid sequence"
468
+ end
469
+
470
+ local keypoints: { ColorSequenceKeypoint } = {}
471
+ for _, point in points do
472
+ local okColour, colour = Serialize.parse(point.value, "Color3")
473
+ if not okColour or typeof(colour) ~= "Color3" then
474
+ return false, nil, string.format("keypoint at %s is not a colour: %q", tostring(point.time), point.value)
475
+ end
476
+ table.insert(keypoints, ColorSequenceKeypoint.new(point.time, colour :: Color3))
477
+ end
478
+ -- Roblox requires the first keypoint at 0 and the last at 1, in order, and
479
+ -- throws an unhelpful error otherwise.
480
+ table.sort(keypoints, function(a, b)
481
+ return a.Time < b.Time
482
+ end)
483
+ if keypoints[1].Time ~= 0 or keypoints[#keypoints].Time ~= 1 then
484
+ return false,
485
+ nil,
486
+ "a ColorSequence must start at time 0 and end at time 1"
487
+ end
488
+ local okBuild, built = pcall(ColorSequence.new, keypoints)
489
+ if not okBuild then
490
+ return false, nil, tostring(built)
491
+ end
492
+ return true, built, nil
493
+ end
494
+
495
+ if valueType == "NumberSequence" then
496
+ if kind == "number" then
497
+ return true, NumberSequence.new(text), nil
498
+ end
499
+ if kind ~= "string" then
500
+ return false, nil, "expected text like \"0=0 1=1\""
501
+ end
502
+ local points = splitKeypoints(text)
503
+ if #points == 0 then
504
+ local single = tonumber(text)
505
+ if single then
506
+ return true, NumberSequence.new(single), nil
507
+ end
508
+ return false, nil, "expected \"0=0 1=1\", or one number for a flat sequence"
509
+ end
510
+
511
+ local keypoints: { NumberSequenceKeypoint } = {}
512
+ for _, point in points do
513
+ local value = tonumber(point.value)
514
+ if not value then
515
+ return false, nil, string.format("keypoint at %s is not a number: %q", tostring(point.time), point.value)
516
+ end
517
+ table.insert(keypoints, NumberSequenceKeypoint.new(point.time, value))
518
+ end
519
+ table.sort(keypoints, function(a, b)
520
+ return a.Time < b.Time
521
+ end)
522
+ if keypoints[1].Time ~= 0 or keypoints[#keypoints].Time ~= 1 then
523
+ return false, nil, "a NumberSequence must start at time 0 and end at time 1"
524
+ end
525
+ local okBuild, built = pcall(NumberSequence.new, keypoints)
526
+ if not okBuild then
527
+ return false, nil, tostring(built)
528
+ end
529
+ return true, built, nil
530
+ end
531
+
532
+ --[[
533
+ Rect, which `Serialize.value` writes and `parse` did not read.
534
+
535
+ The last of the write-but-cannot-read set, after NumberRange and the two
536
+ sequences. Offered in the attribute type list too, so `rect` attributes
537
+ were advertised and refused in the same breath. Four numbers, in the
538
+ order the writer emits them: min X, min Y, max X, max Y.
539
+ ]]
540
+ if valueType == "Rect" then
541
+ if #numbers < 4 then
542
+ return false, nil, 'expected "minX, minY, maxX, maxY"'
543
+ end
544
+ return true, Rect.new(numbers[1], numbers[2], numbers[3], numbers[4]), nil
545
+ end
546
+
303
547
  if valueType == "NumberRange" then
304
- if #numbers < 2 then
305
- return false, nil, 'expected "min..max"'
548
+ if kind == "string" then
549
+ local low, high = string.match(text, "^%s*(-?[%d%.]+)%s*%.%.%s*(-?[%d%.]+)%s*$")
550
+ if low and high and tonumber(low) and tonumber(high) then
551
+ return true, NumberRange.new(tonumber(low) :: number, tonumber(high) :: number), nil
552
+ end
553
+ end
554
+ if #numbers >= 2 then
555
+ return true, NumberRange.new(numbers[1], numbers[2]), nil
556
+ end
557
+ if #numbers == 1 then
558
+ return true, NumberRange.new(numbers[1]), nil
306
559
  end
307
- return true, NumberRange.new(numbers[1], numbers[2]), nil
560
+ return false, nil, 'expected "min..max", e.g. "1..3"'
308
561
  end
309
562
 
310
- -- Enum values arrive as "Enum.Material.Plastic" or bare "Plastic".
311
- local enumType = (Enum :: any)[valueType]
312
- if enumType then
563
+ --[[
564
+ PhysicalProperties, which is neither a vector nor an enum.
565
+
566
+ Written as the Properties panel writes it: density, friction,
567
+ elasticity, and optionally the two weights. Handled here because it is a
568
+ real property on every BasePart and the fall-through below cannot take
569
+ it -- see the note on the Enum lookup.
570
+ ]]
571
+ if valueType == "PhysicalProperties" then
572
+ if #numbers >= 5 then
573
+ return true, PhysicalProperties.new(numbers[1], numbers[2], numbers[3], numbers[4], numbers[5]), nil
574
+ end
575
+ if #numbers >= 3 then
576
+ return true, PhysicalProperties.new(numbers[1], numbers[2], numbers[3]), nil
577
+ end
578
+ return false, nil, 'expected "density, friction, elasticity" and optionally two weights'
579
+ end
580
+
581
+ --[[
582
+ Fonts, which look like an enum and are not one.
583
+
584
+ `TextLabel.FontFace` holds a `Font`, while `Enum.Font` is a separate
585
+ thing that happens to share its names. The fall-through below found
586
+ `Enum.Font` first and returned an EnumItem, so every attempt to set
587
+ FontFace failed with "Font expected, got EnumItem" -- measured while
588
+ building an ordinary shop panel, where it stopped the whole batch. This
589
+ has to come first for that reason.
590
+
591
+ Three spellings are accepted, because all three are what someone
592
+ actually has to hand: the familiar enum name ("GothamBold"), a family
593
+ asset id, and a family with a weight and style after it -- the form this
594
+ module writes when it reads a Font back.
595
+ ]]
596
+ if valueType == "Font" then
597
+ local written = tostring(text)
598
+ local pieces: { string } = {}
599
+ for piece in string.gmatch(written, "[^|]+") do
600
+ table.insert(pieces, (string.gsub(piece, "^%s*(.-)%s*$", "%1")))
601
+ end
602
+
603
+ local family = if #pieces > 0 then pieces[1] else written
604
+ local weight = Enum.FontWeight.Regular
605
+ local style = Enum.FontStyle.Normal
606
+ if #pieces >= 2 then
607
+ local okWeight, found = pcall(function()
608
+ return (Enum.FontWeight :: any)[pieces[2]]
609
+ end)
610
+ if okWeight and found then
611
+ weight = found
612
+ end
613
+ end
614
+ if #pieces >= 3 and pieces[3] == "Italic" then
615
+ style = Enum.FontStyle.Italic
616
+ end
617
+
618
+ -- The enum spelling first: "GothamBold" carries its weight in the name,
619
+ -- and `fromEnum` knows the pairing better than any table here would.
620
+ if #pieces == 1 then
621
+ local name = family:match("([^%.]+)$") or family
622
+ for _, item in Enum.Font:GetEnumItems() do
623
+ if item.Name == name and item ~= Enum.Font.Unknown then
624
+ local okEnumFont, font = pcall(function()
625
+ return Font.fromEnum(item)
626
+ end)
627
+ if okEnumFont then
628
+ return true, font, nil
629
+ end
630
+ end
631
+ end
632
+ end
633
+
634
+ if string.sub(family, 1, 9) == "rbxasset:" or string.sub(family, 1, 12) == "rbxassetid:" then
635
+ return true, Font.new(family, weight, style), nil
636
+ end
637
+
638
+ local okName, font = pcall(function()
639
+ return Font.fromName(family, weight, style)
640
+ end)
641
+ if okName and font then
642
+ return true, font, nil
643
+ end
644
+
645
+ return false,
646
+ nil,
647
+ string.format(
648
+ '"%s" is not a font. Use an Enum.Font name like "GothamBold", a family '
649
+ .. 'name like "Gotham", or "family | Weight | Style".',
650
+ written
651
+ )
652
+ end
653
+
654
+ --[[
655
+ Enum values arrive as "Enum.Material.Plastic" or bare "Plastic".
656
+
657
+ Indexed inside a pcall, because `Enum` throws on an unknown name rather
658
+ than returning nil. Every value type this function does not handle ends
659
+ up here, so an unguarded lookup turned "no conversion known for type X"
660
+ -- a clear message naming the property -- into a raw HANDLER_ERROR
661
+ reading "PhysicalProperties is not a valid member of Enum", which names
662
+ the wrong thing entirely. Measured on a `modify` setting
663
+ CustomPhysicalProperties.
664
+ ]]
665
+ local okEnum, enumType = pcall(function()
666
+ return (Enum :: any)[valueType]
667
+ end)
668
+ if okEnum and enumType then
313
669
  local name = tostring(text):match("([^%.]+)$") or tostring(text)
314
670
  for _, item in enumType:GetEnumItems() do
315
671
  if item.Name == name then
@@ -321,6 +677,82 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
321
677
  string.format('"%s" is not a member of Enum.%s', tostring(text), valueType)
322
678
  end
323
679
 
680
+ --[[
681
+ Instance references, written as the path of the thing pointed at.
682
+
683
+ Motor6D.Part0, WeldConstraint.Part1, ObjectValue.Value, Beam.Attachment0,
684
+ Model.PrimaryPart, BillboardGui.Adornee -- every one of these holds
685
+ another instance, and none of them could be set at all before this. That
686
+ is not a corner: a Motor6D with no Part0 joins nothing, so building a rig
687
+ or welding a gun to a hand fell out of `create` entirely and had to go
688
+ through `execute_luau`. Measured on a six-joint R6 rig, which failed with
689
+ "no conversion known for type BasePart".
690
+
691
+ Written the same way every reference is READ -- `Serialize.value` returns
692
+ `GetFullName()` for an instance -- so a value read here can be written
693
+ straight back, which is the promise the rest of this module makes.
694
+
695
+ The class is checked against the property's own type, because the engine
696
+ accepts the assignment and then quietly does nothing useful with a Part0
697
+ pointing at a Folder. An empty string clears the reference, which is the
698
+ only way to say "nothing" in a field that otherwise takes a path.
699
+ ]]
700
+ if kind == "string" then
701
+ local wanted = tostring(text)
702
+ if wanted == "" then
703
+ return true, nil, nil
704
+ end
705
+ local okPath, resolved = pcall(Paths.resolve, wanted)
706
+ if okPath and typeof(resolved) == "Instance" then
707
+ local instance = resolved :: Instance
708
+ if instance:IsA(valueType) then
709
+ return true, instance, nil
710
+ end
711
+ return false,
712
+ nil,
713
+ string.format(
714
+ "%s is a %s, but this property holds a %s",
715
+ wanted,
716
+ instance.ClassName,
717
+ valueType
718
+ )
719
+ end
720
+
721
+ --[[
722
+ The path did not resolve, and saying so is the whole point.
723
+
724
+ This used to fall through to the "no conversion known for type X"
725
+ line below, which blames the TYPE for what is really a missing
726
+ instance: setting `HingeConstraint.Attachment0` to a path that did
727
+ not exist yet reported `no conversion known for type "Attachment"`,
728
+ sending the reader to look for a serializer that was never the
729
+ problem. Measured while building a hinge in one `create` call.
730
+ ]]
731
+ --[[
732
+ Only text that actually looks like a path is reported as a missing
733
+ instance -- and only that text is worth retrying later.
734
+
735
+ Without this check every unparseable string took this branch: a
736
+ ColorSequence written as "0=1, 0, 0" came back as `nothing at
737
+ "0=1, 0, 0"`, which names the wrong problem entirely and sends the
738
+ create handler off to retry something no amount of waiting will fix.
739
+
740
+ The test is whether the first segment names something real at the
741
+ root of the data model. "Workspace.Nowhere" passes it -- Workspace
742
+ exists, the rest does not, which is exactly the retryable case --
743
+ while "0.5" and "0=1, 0, 0" do not.
744
+ ]]
745
+ local head = string.match(wanted, "^([%a_][%w_]*)%.") or string.match(wanted, "^([%a_][%w_]*)$")
746
+ local looksLikePath = head ~= nil and pcall(function()
747
+ return game:FindFirstChild(head :: string) ~= nil or game:GetService(head :: any) ~= nil
748
+ end)
749
+ if looksLikePath then
750
+ return false,
751
+ nil,
752
+ string.format('%snothing at "%s" (this property holds a %s)', UNRESOLVED_PREFIX, wanted, valueType)
753
+ end
754
+ end
755
+
324
756
  return false, nil, string.format('no conversion known for type "%s"', valueType)
325
757
  end
326
758