@el4cteo/rbx-studio-mcp 0.6.5 → 0.6.8

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 (46) 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 +4 -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 +290 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/audio.js +96 -0
  19. package/dist/tools/audio.js.map +1 -0
  20. package/dist/tools/data.js +133 -3
  21. package/dist/tools/data.js.map +1 -1
  22. package/dist/tools/exec.js +56 -1
  23. package/dist/tools/exec.js.map +1 -1
  24. package/dist/tools/scripts.js +124 -4
  25. package/dist/tools/scripts.js.map +1 -1
  26. package/dist/tools/spatial.js +135 -0
  27. package/dist/tools/spatial.js.map +1 -0
  28. package/dist/tools/universe.js +182 -0
  29. package/dist/tools/universe.js.map +1 -0
  30. package/dist/tools/upload.js +294 -0
  31. package/dist/tools/upload.js.map +1 -0
  32. package/dist/tools/world.js +361 -10
  33. package/dist/tools/world.js.map +1 -1
  34. package/package.json +74 -74
  35. package/plugin/src/Commands.luau +646 -622
  36. package/plugin/src/Config.luau +65 -65
  37. package/plugin/src/Phrase.luau +816 -766
  38. package/plugin/src/Prompt.luau +965 -961
  39. package/plugin/src/Secret.luau +86 -0
  40. package/plugin/src/Serialize.luau +759 -499
  41. package/plugin/src/handlers/Assets.luau +636 -587
  42. package/plugin/src/handlers/Audio.luau +411 -0
  43. package/plugin/src/handlers/Geometry.luau +722 -577
  44. package/plugin/src/handlers/Instances.luau +84 -4
  45. package/plugin/src/handlers/Spatial.luau +334 -0
  46. package/plugin/src/init.server.luau +883 -879
@@ -1,499 +1,759 @@
1
- --!strict
2
- --[[
3
- Converts Roblox values into JSON-safe, token-cheap representations.
4
-
5
- Format mirrors what the Studio Properties panel shows -- Vector3 as
6
- "12, 0, 5", Enums as "Enum.Material.Plastic", instance references as their
7
- path. Two reasons: a Roblox developer reading the transcript sees what they
8
- would see in Studio, and a model has seen far more of that notation in
9
- training than any bespoke JSON envelope. It is also markedly cheaper than
10
- {"__type":"Vector3","x":12,"y":0,"z":5} on every property of every instance.
11
-
12
- `Serialize.parse` is the exact inverse, so a value read here can be written
13
- straight back without the agent reformatting it.
14
- ]]
15
-
16
- local Paths = require(script.Parent.Paths)
17
-
18
- local Serialize = {}
19
-
20
- -- Floats are rounded before display: Studio reports positions like
21
- -- 12.000000476837158, which costs tokens and means nothing to the caller.
22
- local PRECISION = 4
23
-
24
- -- API-dump type names that accept a plain Luau number.
25
- local NUMERIC_TYPES: { [string]: boolean } = {
26
- int = true,
27
- int64 = true,
28
- float = true,
29
- double = true,
30
- number = true,
31
- }
32
-
33
- local function num(value: number): string
34
- local rounded = math.round(value * 10 ^ PRECISION) / 10 ^ PRECISION
35
- if rounded == math.floor(rounded) and math.abs(rounded) < 1e15 then
36
- return string.format("%d", rounded)
37
- end
38
- return tostring(rounded)
39
- end
40
-
41
- --[[
42
- Serializes one value. Unknown userdata falls back to `tostring`, which is
43
- always better than dropping the property silently.
44
- ]]
45
- function Serialize.value(value: any): any
46
- local kind = typeof(value)
47
-
48
- if kind == "string" or kind == "boolean" or kind == "nil" then
49
- return value
50
- end
51
- if kind == "number" then
52
- -- Kept numeric so the agent can do arithmetic without parsing.
53
- return math.round(value * 10 ^ PRECISION) / 10 ^ PRECISION
54
- end
55
- if kind == "Vector3" then
56
- return string.format("%s, %s, %s", num(value.X), num(value.Y), num(value.Z))
57
- end
58
- if kind == "Vector2" then
59
- return string.format("%s, %s", num(value.X), num(value.Y))
60
- end
61
- if kind == "CFrame" then
62
- local position = value.Position
63
- local rx, ry, rz = value:ToOrientation()
64
- return string.format(
65
- "pos %s, %s, %s | rot %s, %s, %s",
66
- num(position.X),
67
- num(position.Y),
68
- num(position.Z),
69
- num(math.deg(rx)),
70
- num(math.deg(ry)),
71
- num(math.deg(rz))
72
- )
73
- end
74
- if kind == "Color3" then
75
- return string.format("%s, %s, %s", num(value.R), num(value.G), num(value.B))
76
- end
77
- if kind == "BrickColor" then
78
- return value.Name
79
- end
80
- if kind == "UDim2" then
81
- return string.format(
82
- "{%s, %s}, {%s, %s}",
83
- num(value.X.Scale),
84
- num(value.X.Offset),
85
- num(value.Y.Scale),
86
- num(value.Y.Offset)
87
- )
88
- end
89
- if kind == "UDim" then
90
- return string.format("{%s, %s}", num(value.Scale), num(value.Offset))
91
- end
92
- if kind == "Font" then
93
- --[[
94
- Written so it can be read back, which `tostring` is not.
95
-
96
- A Font stringifies as `Font { Family = rbxasset://..., Weight = Bold,
97
- Style = Normal }`, which nothing here can parse. The family, weight
98
- and style are all there is to one, so they are written plainly and
99
- separated the way a composite value is elsewhere in this module.
100
- ]]
101
- return string.format(
102
- "%s | %s | %s",
103
- value.Family,
104
- value.Weight.Name,
105
- value.Style.Name
106
- )
107
- end
108
- if kind == "EnumItem" then
109
- return string.format("Enum.%s.%s", tostring(value.EnumType), value.Name)
110
- end
111
- if kind == "Instance" then
112
- --[[
113
- Formatted as an address, not as a display name.
114
-
115
- `GetFullName` writes "StarterPack.Pistol.Handle" for both of two
116
- Tools named Pistol, so reading a Motor6D's Part1 and writing it back
117
- pointed at the wrong one -- silently, since both paths resolve.
118
- `Paths.of` adds the [n] that tells them apart, which is what makes a
119
- value read here safe to write straight back.
120
- ]]
121
- return Paths.of(value)
122
- end
123
- if kind == "NumberRange" then
124
- return string.format("%s..%s", num(value.Min), num(value.Max))
125
- end
126
- if kind == "Rect" then
127
- return string.format(
128
- "%s, %s, %s, %s",
129
- num(value.Min.X),
130
- num(value.Min.Y),
131
- num(value.Max.X),
132
- num(value.Max.Y)
133
- )
134
- end
135
- if kind == "ColorSequence" then
136
- local parts: { string } = {}
137
- for _, keypoint in value.Keypoints do
138
- table.insert(
139
- parts,
140
- string.format("%s=%s", num(keypoint.Time), Serialize.value(keypoint.Value))
141
- )
142
- end
143
- return table.concat(parts, " ")
144
- end
145
- if kind == "NumberSequence" then
146
- local parts: { string } = {}
147
- for _, keypoint in value.Keypoints do
148
- table.insert(parts, string.format("%s=%s", num(keypoint.Time), num(keypoint.Value)))
149
- end
150
- return table.concat(parts, " ")
151
- end
152
- if kind == "table" then
153
- local copy: { [any]: any } = {}
154
- for key, item in value :: { [any]: any } do
155
- copy[key] = Serialize.value(item)
156
- end
157
- return copy
158
- end
159
-
160
- return tostring(value)
161
- end
162
-
163
- --[[
164
- Reads a property without throwing.
165
-
166
- Property access on an instance can error even when the API dump says the
167
- property exists -- some are context-gated (only readable during a playtest,
168
- or only on a loaded asset). Returning nil for those keeps one awkward
169
- property from failing an inspect of 50 instances.
170
- ]]
171
- function Serialize.readProperty(instance: Instance, name: string): (boolean, any)
172
- local ok, value = pcall(function()
173
- return (instance :: any)[name]
174
- end)
175
- if not ok then
176
- return false, nil
177
- end
178
- return true, Serialize.value(value)
179
- end
180
-
181
- --[[
182
- Pulls the numbers out of a composite value written as text.
183
-
184
- This used to be a single `-?%d+%.?%d*` scan, which is not the grammar Luau
185
- numbers actually have: "1e3, 0, .5" came back as 1, 3 and 0, so a Position
186
- written in scientific notation was silently set to a different point in the
187
- world and reported as applied. Splitting on the separators and handing each
188
- token to `tonumber` means this agrees with the scalar path, which always
189
- used `tonumber` -- the two disagreeing about what counts as a number was the
190
- whole bug.
191
-
192
- Non-numeric tokens are dropped, so "Vector3.new(1, 2, 3)" and "{1, 2, 3}"
193
- still read as three numbers.
194
- ]]
195
- local function scanTokens(text: string): { number }
196
- local numbers: { number } = {}
197
- for token in string.gmatch(text, "[^,%s%(%)%[%]{}<>]+") do
198
- local value = tonumber(token)
199
- if value then
200
- table.insert(numbers, value)
201
- end
202
- end
203
- return numbers
204
- end
205
-
206
- --[[
207
- Parses a serialized string back into a Roblox value, given the target type
208
- from the API dump. Returns ok=false with a reason the agent can act on
209
- rather than raising, so batch writes can report per-property failures.
210
- ]]
211
- function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
212
- local kind = typeof(text)
213
-
214
- -- Values already of the right primitive shape pass straight through. The
215
- -- number case is matched against the target type rather than excluded from
216
- -- one: a bare 5 is a valid `float`, but not a valid Vector3, Enum or Color3.
217
- if kind == "boolean" then
218
- return true, text, nil
219
- end
220
- if kind == "number" and NUMERIC_TYPES[valueType] then
221
- return true, text, nil
222
- end
223
-
224
- if valueType == "string" or valueType == "Content" or valueType == "ProtectedString" then
225
- return true, tostring(text), nil
226
- end
227
- if valueType == "bool" then
228
- if kind == "boolean" then
229
- return true, text, nil
230
- end
231
- if kind == "number" then
232
- return true, text ~= 0, nil
233
- end
234
- -- This used to be `text == "true"`, which quietly turned "True", "TRUE"
235
- -- and "1" into false and reported the write as applied. A bool that is
236
- -- silently the opposite of what was asked is the worst failure this
237
- -- module can produce, so near misses are accepted and anything else is
238
- -- refused rather than guessed at.
239
- local lowered = string.lower(tostring(text))
240
- if lowered == "true" or lowered == "1" or lowered == "yes" then
241
- return true, true, nil
242
- end
243
- if lowered == "false" or lowered == "0" or lowered == "no" then
244
- return true, false, nil
245
- end
246
- return false, nil, string.format('expected true or false, got "%s"', tostring(text))
247
- end
248
- if NUMERIC_TYPES[valueType] then
249
- local parsed = tonumber(text)
250
- if not parsed then
251
- return false, nil, string.format('expected a number, got "%s"', tostring(text))
252
- end
253
- return true, parsed, nil
254
- end
255
-
256
- local numbers: { number } = {}
257
- if kind == "string" then
258
- for _, token in scanTokens(text) do
259
- table.insert(numbers, token)
260
- end
261
- elseif kind == "table" then
262
- for _, item in text :: { any } do
263
- local parsed = tonumber(item)
264
- if parsed then
265
- table.insert(numbers, parsed)
266
- end
267
- end
268
- end
269
-
270
- if valueType == "Vector3" then
271
- if #numbers < 3 then
272
- return false, nil, 'expected three numbers, e.g. "12, 0, 5"'
273
- end
274
- return true, Vector3.new(numbers[1], numbers[2], numbers[3]), nil
275
- end
276
- if valueType == "Vector2" then
277
- if #numbers < 2 then
278
- return false, nil, 'expected two numbers, e.g. "12, 5"'
279
- end
280
- return true, Vector2.new(numbers[1], numbers[2]), nil
281
- end
282
- if valueType == "Color3" then
283
- if #numbers < 3 then
284
- return false, nil, 'expected three 0-1 components, e.g. "1, 0.5, 0"'
285
- end
286
- -- 0-255 is a common mistake and unambiguous to detect, so accept it.
287
- local scale = if numbers[1] > 1 or numbers[2] > 1 or numbers[3] > 1 then 255 else 1
288
- return true, Color3.new(numbers[1] / scale, numbers[2] / scale, numbers[3] / scale), nil
289
- end
290
- if valueType == "BrickColor" then
291
- -- Cast because the API definitions type this overload as a literal union
292
- -- of every BrickColor name. At runtime it takes any string and throws on
293
- -- an unknown one, which is exactly what the pcall is here to catch.
294
- local ok, color = pcall(function()
295
- return BrickColor.new(tostring(text) :: any)
296
- end)
297
- if not ok then
298
- return false, nil, string.format('"%s" is not a BrickColor name', tostring(text))
299
- end
300
- return true, color, nil
301
- end
302
- if valueType == "UDim2" then
303
- if #numbers < 4 then
304
- return false, nil, 'expected four numbers, e.g. "{0.5, 10}, {0.5, 20}"'
305
- end
306
- return true, UDim2.new(numbers[1], numbers[2], numbers[3], numbers[4]), nil
307
- end
308
- if valueType == "UDim" then
309
- if #numbers < 2 then
310
- return false, nil, 'expected two numbers, e.g. "{0.5, 10}"'
311
- end
312
- return true, UDim.new(numbers[1], numbers[2]), nil
313
- end
314
- if valueType == "CFrame" then
315
- if #numbers >= 6 then
316
- return true,
317
- CFrame.new(numbers[1], numbers[2], numbers[3])
318
- * CFrame.fromOrientation(
319
- math.rad(numbers[4]),
320
- math.rad(numbers[5]),
321
- math.rad(numbers[6])
322
- ),
323
- nil
324
- end
325
- if #numbers >= 3 then
326
- return true, CFrame.new(numbers[1], numbers[2], numbers[3]), nil
327
- end
328
- return false, nil, 'expected "pos x, y, z" or "pos x, y, z | rot rx, ry, rz"'
329
- end
330
- if valueType == "NumberRange" then
331
- if #numbers < 2 then
332
- return false, nil, 'expected "min..max"'
333
- end
334
- return true, NumberRange.new(numbers[1], numbers[2]), nil
335
- end
336
-
337
- --[[
338
- PhysicalProperties, which is neither a vector nor an enum.
339
-
340
- Written as the Properties panel writes it: density, friction,
341
- elasticity, and optionally the two weights. Handled here because it is a
342
- real property on every BasePart and the fall-through below cannot take
343
- it -- see the note on the Enum lookup.
344
- ]]
345
- if valueType == "PhysicalProperties" then
346
- if #numbers >= 5 then
347
- return true, PhysicalProperties.new(numbers[1], numbers[2], numbers[3], numbers[4], numbers[5]), nil
348
- end
349
- if #numbers >= 3 then
350
- return true, PhysicalProperties.new(numbers[1], numbers[2], numbers[3]), nil
351
- end
352
- return false, nil, 'expected "density, friction, elasticity" and optionally two weights'
353
- end
354
-
355
- --[[
356
- Fonts, which look like an enum and are not one.
357
-
358
- `TextLabel.FontFace` holds a `Font`, while `Enum.Font` is a separate
359
- thing that happens to share its names. The fall-through below found
360
- `Enum.Font` first and returned an EnumItem, so every attempt to set
361
- FontFace failed with "Font expected, got EnumItem" -- measured while
362
- building an ordinary shop panel, where it stopped the whole batch. This
363
- has to come first for that reason.
364
-
365
- Three spellings are accepted, because all three are what someone
366
- actually has to hand: the familiar enum name ("GothamBold"), a family
367
- asset id, and a family with a weight and style after it -- the form this
368
- module writes when it reads a Font back.
369
- ]]
370
- if valueType == "Font" then
371
- local written = tostring(text)
372
- local pieces: { string } = {}
373
- for piece in string.gmatch(written, "[^|]+") do
374
- table.insert(pieces, (string.gsub(piece, "^%s*(.-)%s*$", "%1")))
375
- end
376
-
377
- local family = if #pieces > 0 then pieces[1] else written
378
- local weight = Enum.FontWeight.Regular
379
- local style = Enum.FontStyle.Normal
380
- if #pieces >= 2 then
381
- local okWeight, found = pcall(function()
382
- return (Enum.FontWeight :: any)[pieces[2]]
383
- end)
384
- if okWeight and found then
385
- weight = found
386
- end
387
- end
388
- if #pieces >= 3 and pieces[3] == "Italic" then
389
- style = Enum.FontStyle.Italic
390
- end
391
-
392
- -- The enum spelling first: "GothamBold" carries its weight in the name,
393
- -- and `fromEnum` knows the pairing better than any table here would.
394
- if #pieces == 1 then
395
- local name = family:match("([^%.]+)$") or family
396
- for _, item in Enum.Font:GetEnumItems() do
397
- if item.Name == name and item ~= Enum.Font.Unknown then
398
- local okEnumFont, font = pcall(function()
399
- return Font.fromEnum(item)
400
- end)
401
- if okEnumFont then
402
- return true, font, nil
403
- end
404
- end
405
- end
406
- end
407
-
408
- if string.sub(family, 1, 9) == "rbxasset:" or string.sub(family, 1, 12) == "rbxassetid:" then
409
- return true, Font.new(family, weight, style), nil
410
- end
411
-
412
- local okName, font = pcall(function()
413
- return Font.fromName(family, weight, style)
414
- end)
415
- if okName and font then
416
- return true, font, nil
417
- end
418
-
419
- return false,
420
- nil,
421
- string.format(
422
- '"%s" is not a font. Use an Enum.Font name like "GothamBold", a family '
423
- .. 'name like "Gotham", or "family | Weight | Style".',
424
- written
425
- )
426
- end
427
-
428
- --[[
429
- Enum values arrive as "Enum.Material.Plastic" or bare "Plastic".
430
-
431
- Indexed inside a pcall, because `Enum` throws on an unknown name rather
432
- than returning nil. Every value type this function does not handle ends
433
- up here, so an unguarded lookup turned "no conversion known for type X"
434
- -- a clear message naming the property -- into a raw HANDLER_ERROR
435
- reading "PhysicalProperties is not a valid member of Enum", which names
436
- the wrong thing entirely. Measured on a `modify` setting
437
- CustomPhysicalProperties.
438
- ]]
439
- local okEnum, enumType = pcall(function()
440
- return (Enum :: any)[valueType]
441
- end)
442
- if okEnum and enumType then
443
- local name = tostring(text):match("([^%.]+)$") or tostring(text)
444
- for _, item in enumType:GetEnumItems() do
445
- if item.Name == name then
446
- return true, item, nil
447
- end
448
- end
449
- return false,
450
- nil,
451
- string.format('"%s" is not a member of Enum.%s', tostring(text), valueType)
452
- end
453
-
454
- --[[
455
- Instance references, written as the path of the thing pointed at.
456
-
457
- Motor6D.Part0, WeldConstraint.Part1, ObjectValue.Value, Beam.Attachment0,
458
- Model.PrimaryPart, BillboardGui.Adornee -- every one of these holds
459
- another instance, and none of them could be set at all before this. That
460
- is not a corner: a Motor6D with no Part0 joins nothing, so building a rig
461
- or welding a gun to a hand fell out of `create` entirely and had to go
462
- through `execute_luau`. Measured on a six-joint R6 rig, which failed with
463
- "no conversion known for type BasePart".
464
-
465
- Written the same way every reference is READ -- `Serialize.value` returns
466
- `GetFullName()` for an instance -- so a value read here can be written
467
- straight back, which is the promise the rest of this module makes.
468
-
469
- The class is checked against the property's own type, because the engine
470
- accepts the assignment and then quietly does nothing useful with a Part0
471
- pointing at a Folder. An empty string clears the reference, which is the
472
- only way to say "nothing" in a field that otherwise takes a path.
473
- ]]
474
- if kind == "string" then
475
- local wanted = tostring(text)
476
- if wanted == "" then
477
- return true, nil, nil
478
- end
479
- local okPath, resolved = pcall(Paths.resolve, wanted)
480
- if okPath and typeof(resolved) == "Instance" then
481
- local instance = resolved :: Instance
482
- if instance:IsA(valueType) then
483
- return true, instance, nil
484
- end
485
- return false,
486
- nil,
487
- string.format(
488
- "%s is a %s, but this property holds a %s",
489
- wanted,
490
- instance.ClassName,
491
- valueType
492
- )
493
- end
494
- end
495
-
496
- return false, nil, string.format('no conversion known for type "%s"', valueType)
497
- end
498
-
499
- return Serialize
1
+ --!strict
2
+ --[[
3
+ Converts Roblox values into JSON-safe, token-cheap representations.
4
+
5
+ Format mirrors what the Studio Properties panel shows -- Vector3 as
6
+ "12, 0, 5", Enums as "Enum.Material.Plastic", instance references as their
7
+ path. Two reasons: a Roblox developer reading the transcript sees what they
8
+ would see in Studio, and a model has seen far more of that notation in
9
+ training than any bespoke JSON envelope. It is also markedly cheaper than
10
+ {"__type":"Vector3","x":12,"y":0,"z":5} on every property of every instance.
11
+
12
+ `Serialize.parse` is the exact inverse, so a value read here can be written
13
+ straight back without the agent reformatting it.
14
+ ]]
15
+
16
+ local Paths = require(script.Parent.Paths)
17
+
18
+ local Serialize = {}
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
+
31
+ -- Floats are rounded before display: Studio reports positions like
32
+ -- 12.000000476837158, which costs tokens and means nothing to the caller.
33
+ local PRECISION = 4
34
+
35
+ -- API-dump type names that accept a plain Luau number.
36
+ local NUMERIC_TYPES: { [string]: boolean } = {
37
+ int = true,
38
+ int64 = true,
39
+ float = true,
40
+ double = true,
41
+ number = true,
42
+ }
43
+
44
+ local function num(value: number): string
45
+ local rounded = math.round(value * 10 ^ PRECISION) / 10 ^ PRECISION
46
+ if rounded == math.floor(rounded) and math.abs(rounded) < 1e15 then
47
+ return string.format("%d", rounded)
48
+ end
49
+ return tostring(rounded)
50
+ end
51
+
52
+ --[[
53
+ Serializes one value. Unknown userdata falls back to `tostring`, which is
54
+ always better than dropping the property silently.
55
+ ]]
56
+ function Serialize.value(value: any): any
57
+ local kind = typeof(value)
58
+
59
+ if kind == "string" or kind == "boolean" or kind == "nil" then
60
+ return value
61
+ end
62
+ if kind == "number" then
63
+ -- Kept numeric so the agent can do arithmetic without parsing.
64
+ return math.round(value * 10 ^ PRECISION) / 10 ^ PRECISION
65
+ end
66
+ if kind == "Vector3" then
67
+ return string.format("%s, %s, %s", num(value.X), num(value.Y), num(value.Z))
68
+ end
69
+ if kind == "Vector2" then
70
+ return string.format("%s, %s", num(value.X), num(value.Y))
71
+ end
72
+ if kind == "CFrame" then
73
+ local position = value.Position
74
+ local rx, ry, rz = value:ToOrientation()
75
+ return string.format(
76
+ "pos %s, %s, %s | rot %s, %s, %s",
77
+ num(position.X),
78
+ num(position.Y),
79
+ num(position.Z),
80
+ num(math.deg(rx)),
81
+ num(math.deg(ry)),
82
+ num(math.deg(rz))
83
+ )
84
+ end
85
+ if kind == "Color3" then
86
+ return string.format("%s, %s, %s", num(value.R), num(value.G), num(value.B))
87
+ end
88
+ if kind == "BrickColor" then
89
+ return value.Name
90
+ end
91
+ if kind == "UDim2" then
92
+ return string.format(
93
+ "{%s, %s}, {%s, %s}",
94
+ num(value.X.Scale),
95
+ num(value.X.Offset),
96
+ num(value.Y.Scale),
97
+ num(value.Y.Offset)
98
+ )
99
+ end
100
+ if kind == "UDim" then
101
+ return string.format("{%s, %s}", num(value.Scale), num(value.Offset))
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
119
+ if kind == "EnumItem" then
120
+ return string.format("Enum.%s.%s", tostring(value.EnumType), value.Name)
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
140
+ if kind == "Instance" then
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)
151
+ end
152
+ if kind == "NumberRange" then
153
+ return string.format("%s..%s", num(value.Min), num(value.Max))
154
+ end
155
+ if kind == "Rect" then
156
+ return string.format(
157
+ "%s, %s, %s, %s",
158
+ num(value.Min.X),
159
+ num(value.Min.Y),
160
+ num(value.Max.X),
161
+ num(value.Max.Y)
162
+ )
163
+ end
164
+ if kind == "ColorSequence" then
165
+ local parts: { string } = {}
166
+ for _, keypoint in value.Keypoints do
167
+ table.insert(
168
+ parts,
169
+ string.format("%s=%s", num(keypoint.Time), Serialize.value(keypoint.Value))
170
+ )
171
+ end
172
+ return table.concat(parts, " ")
173
+ end
174
+ if kind == "NumberSequence" then
175
+ local parts: { string } = {}
176
+ for _, keypoint in value.Keypoints do
177
+ table.insert(parts, string.format("%s=%s", num(keypoint.Time), num(keypoint.Value)))
178
+ end
179
+ return table.concat(parts, " ")
180
+ end
181
+ if kind == "table" then
182
+ local copy: { [any]: any } = {}
183
+ for key, item in value :: { [any]: any } do
184
+ copy[key] = Serialize.value(item)
185
+ end
186
+ return copy
187
+ end
188
+
189
+ return tostring(value)
190
+ end
191
+
192
+ --[[
193
+ Reads a property without throwing.
194
+
195
+ Property access on an instance can error even when the API dump says the
196
+ property exists -- some are context-gated (only readable during a playtest,
197
+ or only on a loaded asset). Returning nil for those keeps one awkward
198
+ property from failing an inspect of 50 instances.
199
+ ]]
200
+ function Serialize.readProperty(instance: Instance, name: string): (boolean, any)
201
+ local ok, value = pcall(function()
202
+ return (instance :: any)[name]
203
+ end)
204
+ if not ok then
205
+ return false, nil
206
+ end
207
+ return true, Serialize.value(value)
208
+ end
209
+
210
+ --[[
211
+ Pulls the numbers out of a composite value written as text.
212
+
213
+ This used to be a single `-?%d+%.?%d*` scan, which is not the grammar Luau
214
+ numbers actually have: "1e3, 0, .5" came back as 1, 3 and 0, so a Position
215
+ written in scientific notation was silently set to a different point in the
216
+ world and reported as applied. Splitting on the separators and handing each
217
+ token to `tonumber` means this agrees with the scalar path, which always
218
+ used `tonumber` -- the two disagreeing about what counts as a number was the
219
+ whole bug.
220
+
221
+ Non-numeric tokens are dropped, so "Vector3.new(1, 2, 3)" and "{1, 2, 3}"
222
+ still read as three numbers.
223
+ ]]
224
+ local function scanTokens(text: string): { number }
225
+ local numbers: { number } = {}
226
+ for token in string.gmatch(text, "[^,%s%(%)%[%]{}<>]+") do
227
+ local value = tonumber(token)
228
+ if value then
229
+ table.insert(numbers, value)
230
+ end
231
+ end
232
+ return numbers
233
+ end
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
+
280
+ --[[
281
+ Parses a serialized string back into a Roblox value, given the target type
282
+ from the API dump. Returns ok=false with a reason the agent can act on
283
+ rather than raising, so batch writes can report per-property failures.
284
+ ]]
285
+ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
286
+ local kind = typeof(text)
287
+
288
+ -- Values already of the right primitive shape pass straight through. The
289
+ -- number case is matched against the target type rather than excluded from
290
+ -- one: a bare 5 is a valid `float`, but not a valid Vector3, Enum or Color3.
291
+ if kind == "boolean" then
292
+ return true, text, nil
293
+ end
294
+ if kind == "number" and NUMERIC_TYPES[valueType] then
295
+ return true, text, nil
296
+ end
297
+
298
+ if valueType == "string" or valueType == "ProtectedString" then
299
+ return true, tostring(text), nil
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
340
+ if valueType == "bool" then
341
+ if kind == "boolean" then
342
+ return true, text, nil
343
+ end
344
+ if kind == "number" then
345
+ return true, text ~= 0, nil
346
+ end
347
+ -- This used to be `text == "true"`, which quietly turned "True", "TRUE"
348
+ -- and "1" into false and reported the write as applied. A bool that is
349
+ -- silently the opposite of what was asked is the worst failure this
350
+ -- module can produce, so near misses are accepted and anything else is
351
+ -- refused rather than guessed at.
352
+ local lowered = string.lower(tostring(text))
353
+ if lowered == "true" or lowered == "1" or lowered == "yes" then
354
+ return true, true, nil
355
+ end
356
+ if lowered == "false" or lowered == "0" or lowered == "no" then
357
+ return true, false, nil
358
+ end
359
+ return false, nil, string.format('expected true or false, got "%s"', tostring(text))
360
+ end
361
+ if NUMERIC_TYPES[valueType] then
362
+ local parsed = tonumber(text)
363
+ if not parsed then
364
+ return false, nil, string.format('expected a number, got "%s"', tostring(text))
365
+ end
366
+ return true, parsed, nil
367
+ end
368
+
369
+ local numbers: { number } = {}
370
+ if kind == "string" then
371
+ for _, token in scanTokens(text) do
372
+ table.insert(numbers, token)
373
+ end
374
+ elseif kind == "table" then
375
+ for _, item in text :: { any } do
376
+ local parsed = tonumber(item)
377
+ if parsed then
378
+ table.insert(numbers, parsed)
379
+ end
380
+ end
381
+ end
382
+
383
+ if valueType == "Vector3" then
384
+ if #numbers < 3 then
385
+ return false, nil, 'expected three numbers, e.g. "12, 0, 5"'
386
+ end
387
+ return true, Vector3.new(numbers[1], numbers[2], numbers[3]), nil
388
+ end
389
+ if valueType == "Vector2" then
390
+ if #numbers < 2 then
391
+ return false, nil, 'expected two numbers, e.g. "12, 5"'
392
+ end
393
+ return true, Vector2.new(numbers[1], numbers[2]), nil
394
+ end
395
+ if valueType == "Color3" then
396
+ if #numbers < 3 then
397
+ return false, nil, 'expected three 0-1 components, e.g. "1, 0.5, 0"'
398
+ end
399
+ -- 0-255 is a common mistake and unambiguous to detect, so accept it.
400
+ local scale = if numbers[1] > 1 or numbers[2] > 1 or numbers[3] > 1 then 255 else 1
401
+ return true, Color3.new(numbers[1] / scale, numbers[2] / scale, numbers[3] / scale), nil
402
+ end
403
+ if valueType == "BrickColor" then
404
+ -- Cast because the API definitions type this overload as a literal union
405
+ -- of every BrickColor name. At runtime it takes any string and throws on
406
+ -- an unknown one, which is exactly what the pcall is here to catch.
407
+ local ok, color = pcall(function()
408
+ return BrickColor.new(tostring(text) :: any)
409
+ end)
410
+ if not ok then
411
+ return false, nil, string.format('"%s" is not a BrickColor name', tostring(text))
412
+ end
413
+ return true, color, nil
414
+ end
415
+ if valueType == "UDim2" then
416
+ if #numbers < 4 then
417
+ return false, nil, 'expected four numbers, e.g. "{0.5, 10}, {0.5, 20}"'
418
+ end
419
+ return true, UDim2.new(numbers[1], numbers[2], numbers[3], numbers[4]), nil
420
+ end
421
+ if valueType == "UDim" then
422
+ if #numbers < 2 then
423
+ return false, nil, 'expected two numbers, e.g. "{0.5, 10}"'
424
+ end
425
+ return true, UDim.new(numbers[1], numbers[2]), nil
426
+ end
427
+ if valueType == "CFrame" then
428
+ if #numbers >= 6 then
429
+ return true,
430
+ CFrame.new(numbers[1], numbers[2], numbers[3])
431
+ * CFrame.fromOrientation(
432
+ math.rad(numbers[4]),
433
+ math.rad(numbers[5]),
434
+ math.rad(numbers[6])
435
+ ),
436
+ nil
437
+ end
438
+ if #numbers >= 3 then
439
+ return true, CFrame.new(numbers[1], numbers[2], numbers[3]), nil
440
+ end
441
+ return false, nil, 'expected "pos x, y, z" or "pos x, y, z | rot rx, ry, rz"'
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
+
547
+ if valueType == "NumberRange" then
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
559
+ end
560
+ return false, nil, 'expected "min..max", e.g. "1..3"'
561
+ end
562
+
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
669
+ local name = tostring(text):match("([^%.]+)$") or tostring(text)
670
+ for _, item in enumType:GetEnumItems() do
671
+ if item.Name == name then
672
+ return true, item, nil
673
+ end
674
+ end
675
+ return false,
676
+ nil,
677
+ string.format('"%s" is not a member of Enum.%s', tostring(text), valueType)
678
+ end
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
+
756
+ return false, nil, string.format('no conversion known for type "%s"', valueType)
757
+ end
758
+
759
+ return Serialize