@el4cteo/rbx-studio-mcp 0.6.0 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +4 -4
  2. package/dist/index.js +4 -0
  3. package/dist/index.js.map +1 -1
  4. package/dist/tools/anim.js +159 -0
  5. package/dist/tools/anim.js.map +1 -0
  6. package/dist/tools/character.js +95 -5
  7. package/dist/tools/character.js.map +1 -1
  8. package/dist/tools/data.js +173 -0
  9. package/dist/tools/data.js.map +1 -0
  10. package/dist/tools/device.js +77 -7
  11. package/dist/tools/device.js.map +1 -1
  12. package/dist/tools/discover.js +80 -4
  13. package/dist/tools/discover.js.map +1 -1
  14. package/dist/tools/exec.js +48 -1
  15. package/dist/tools/exec.js.map +1 -1
  16. package/dist/tools/input.js +35 -9
  17. package/dist/tools/input.js.map +1 -1
  18. package/dist/tools/perf.js +74 -7
  19. package/dist/tools/perf.js.map +1 -1
  20. package/dist/tools/scripts.js +51 -3
  21. package/dist/tools/scripts.js.map +1 -1
  22. package/dist/tools/world.js +317 -44
  23. package/dist/tools/world.js.map +1 -1
  24. package/package.json +74 -74
  25. package/plugin/src/Commands.luau +622 -562
  26. package/plugin/src/Config.luau +65 -65
  27. package/plugin/src/Console.luau +1909 -1843
  28. package/plugin/src/Emulation.luau +172 -0
  29. package/plugin/src/Phrase.luau +164 -16
  30. package/plugin/src/Png.luau +8 -4
  31. package/plugin/src/Serialize.luau +499 -327
  32. package/plugin/src/Undo.luau +94 -6
  33. package/plugin/src/handlers/Anim.luau +897 -0
  34. package/plugin/src/handlers/Assets.luau +587 -352
  35. package/plugin/src/handlers/Capture.luau +155 -20
  36. package/plugin/src/handlers/Character.luau +823 -361
  37. package/plugin/src/handlers/Data.luau +539 -0
  38. package/plugin/src/handlers/Device.luau +394 -139
  39. package/plugin/src/handlers/Discover.luau +685 -363
  40. package/plugin/src/handlers/Geometry.luau +127 -0
  41. package/plugin/src/handlers/Perf.luau +227 -0
  42. package/plugin/src/handlers/Scripts.luau +673 -539
  43. package/plugin/src/handlers/Session.luau +3 -0
  44. package/plugin/src/handlers/Viewport.luau +268 -0
  45. package/plugin/src/handlers/World.luau +89 -15
  46. package/plugin/src/init.server.luau +879 -832
  47. package/scripts/build-plugin.mjs +20 -0
  48. package/scripts/check-plugin.mjs +171 -124
@@ -1,327 +1,499 @@
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 Serialize = {}
17
-
18
- -- Floats are rounded before display: Studio reports positions like
19
- -- 12.000000476837158, which costs tokens and means nothing to the caller.
20
- local PRECISION = 4
21
-
22
- -- API-dump type names that accept a plain Luau number.
23
- local NUMERIC_TYPES: { [string]: boolean } = {
24
- int = true,
25
- int64 = true,
26
- float = true,
27
- double = true,
28
- number = true,
29
- }
30
-
31
- local function num(value: number): string
32
- local rounded = math.round(value * 10 ^ PRECISION) / 10 ^ PRECISION
33
- if rounded == math.floor(rounded) and math.abs(rounded) < 1e15 then
34
- return string.format("%d", rounded)
35
- end
36
- return tostring(rounded)
37
- end
38
-
39
- --[[
40
- Serializes one value. Unknown userdata falls back to `tostring`, which is
41
- always better than dropping the property silently.
42
- ]]
43
- function Serialize.value(value: any): any
44
- local kind = typeof(value)
45
-
46
- if kind == "string" or kind == "boolean" or kind == "nil" then
47
- return value
48
- end
49
- if kind == "number" then
50
- -- Kept numeric so the agent can do arithmetic without parsing.
51
- return math.round(value * 10 ^ PRECISION) / 10 ^ PRECISION
52
- end
53
- if kind == "Vector3" then
54
- return string.format("%s, %s, %s", num(value.X), num(value.Y), num(value.Z))
55
- end
56
- if kind == "Vector2" then
57
- return string.format("%s, %s", num(value.X), num(value.Y))
58
- end
59
- if kind == "CFrame" then
60
- local position = value.Position
61
- local rx, ry, rz = value:ToOrientation()
62
- return string.format(
63
- "pos %s, %s, %s | rot %s, %s, %s",
64
- num(position.X),
65
- num(position.Y),
66
- num(position.Z),
67
- num(math.deg(rx)),
68
- num(math.deg(ry)),
69
- num(math.deg(rz))
70
- )
71
- end
72
- if kind == "Color3" then
73
- return string.format("%s, %s, %s", num(value.R), num(value.G), num(value.B))
74
- end
75
- if kind == "BrickColor" then
76
- return value.Name
77
- end
78
- if kind == "UDim2" then
79
- return string.format(
80
- "{%s, %s}, {%s, %s}",
81
- num(value.X.Scale),
82
- num(value.X.Offset),
83
- num(value.Y.Scale),
84
- num(value.Y.Offset)
85
- )
86
- end
87
- if kind == "UDim" then
88
- return string.format("{%s, %s}", num(value.Scale), num(value.Offset))
89
- end
90
- if kind == "EnumItem" then
91
- return string.format("Enum.%s.%s", tostring(value.EnumType), value.Name)
92
- end
93
- if kind == "Instance" then
94
- return value:GetFullName()
95
- end
96
- if kind == "NumberRange" then
97
- return string.format("%s..%s", num(value.Min), num(value.Max))
98
- end
99
- if kind == "Rect" then
100
- return string.format(
101
- "%s, %s, %s, %s",
102
- num(value.Min.X),
103
- num(value.Min.Y),
104
- num(value.Max.X),
105
- num(value.Max.Y)
106
- )
107
- end
108
- if kind == "ColorSequence" then
109
- local parts: { string } = {}
110
- for _, keypoint in value.Keypoints do
111
- table.insert(
112
- parts,
113
- string.format("%s=%s", num(keypoint.Time), Serialize.value(keypoint.Value))
114
- )
115
- end
116
- return table.concat(parts, " ")
117
- end
118
- if kind == "NumberSequence" then
119
- local parts: { string } = {}
120
- for _, keypoint in value.Keypoints do
121
- table.insert(parts, string.format("%s=%s", num(keypoint.Time), num(keypoint.Value)))
122
- end
123
- return table.concat(parts, " ")
124
- end
125
- if kind == "table" then
126
- local copy: { [any]: any } = {}
127
- for key, item in value :: { [any]: any } do
128
- copy[key] = Serialize.value(item)
129
- end
130
- return copy
131
- end
132
-
133
- return tostring(value)
134
- end
135
-
136
- --[[
137
- Reads a property without throwing.
138
-
139
- Property access on an instance can error even when the API dump says the
140
- property exists -- some are context-gated (only readable during a playtest,
141
- or only on a loaded asset). Returning nil for those keeps one awkward
142
- property from failing an inspect of 50 instances.
143
- ]]
144
- function Serialize.readProperty(instance: Instance, name: string): (boolean, any)
145
- local ok, value = pcall(function()
146
- return (instance :: any)[name]
147
- end)
148
- if not ok then
149
- return false, nil
150
- end
151
- return true, Serialize.value(value)
152
- end
153
-
154
- --[[
155
- Pulls the numbers out of a composite value written as text.
156
-
157
- This used to be a single `-?%d+%.?%d*` scan, which is not the grammar Luau
158
- numbers actually have: "1e3, 0, .5" came back as 1, 3 and 0, so a Position
159
- written in scientific notation was silently set to a different point in the
160
- world and reported as applied. Splitting on the separators and handing each
161
- token to `tonumber` means this agrees with the scalar path, which always
162
- used `tonumber` -- the two disagreeing about what counts as a number was the
163
- whole bug.
164
-
165
- Non-numeric tokens are dropped, so "Vector3.new(1, 2, 3)" and "{1, 2, 3}"
166
- still read as three numbers.
167
- ]]
168
- local function scanTokens(text: string): { number }
169
- local numbers: { number } = {}
170
- for token in string.gmatch(text, "[^,%s%(%)%[%]{}<>]+") do
171
- local value = tonumber(token)
172
- if value then
173
- table.insert(numbers, value)
174
- end
175
- end
176
- return numbers
177
- end
178
-
179
- --[[
180
- Parses a serialized string back into a Roblox value, given the target type
181
- from the API dump. Returns ok=false with a reason the agent can act on
182
- rather than raising, so batch writes can report per-property failures.
183
- ]]
184
- function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
185
- local kind = typeof(text)
186
-
187
- -- Values already of the right primitive shape pass straight through. The
188
- -- number case is matched against the target type rather than excluded from
189
- -- one: a bare 5 is a valid `float`, but not a valid Vector3, Enum or Color3.
190
- if kind == "boolean" then
191
- return true, text, nil
192
- end
193
- if kind == "number" and NUMERIC_TYPES[valueType] then
194
- return true, text, nil
195
- end
196
-
197
- if valueType == "string" or valueType == "Content" or valueType == "ProtectedString" then
198
- return true, tostring(text), nil
199
- end
200
- if valueType == "bool" then
201
- if kind == "boolean" then
202
- return true, text, nil
203
- end
204
- if kind == "number" then
205
- return true, text ~= 0, nil
206
- end
207
- -- This used to be `text == "true"`, which quietly turned "True", "TRUE"
208
- -- and "1" into false and reported the write as applied. A bool that is
209
- -- silently the opposite of what was asked is the worst failure this
210
- -- module can produce, so near misses are accepted and anything else is
211
- -- refused rather than guessed at.
212
- local lowered = string.lower(tostring(text))
213
- if lowered == "true" or lowered == "1" or lowered == "yes" then
214
- return true, true, nil
215
- end
216
- if lowered == "false" or lowered == "0" or lowered == "no" then
217
- return true, false, nil
218
- end
219
- return false, nil, string.format('expected true or false, got "%s"', tostring(text))
220
- end
221
- if NUMERIC_TYPES[valueType] then
222
- local parsed = tonumber(text)
223
- if not parsed then
224
- return false, nil, string.format('expected a number, got "%s"', tostring(text))
225
- end
226
- return true, parsed, nil
227
- end
228
-
229
- local numbers: { number } = {}
230
- if kind == "string" then
231
- for _, token in scanTokens(text) do
232
- table.insert(numbers, token)
233
- end
234
- elseif kind == "table" then
235
- for _, item in text :: { any } do
236
- local parsed = tonumber(item)
237
- if parsed then
238
- table.insert(numbers, parsed)
239
- end
240
- end
241
- end
242
-
243
- if valueType == "Vector3" then
244
- if #numbers < 3 then
245
- return false, nil, 'expected three numbers, e.g. "12, 0, 5"'
246
- end
247
- return true, Vector3.new(numbers[1], numbers[2], numbers[3]), nil
248
- end
249
- if valueType == "Vector2" then
250
- if #numbers < 2 then
251
- return false, nil, 'expected two numbers, e.g. "12, 5"'
252
- end
253
- return true, Vector2.new(numbers[1], numbers[2]), nil
254
- end
255
- if valueType == "Color3" then
256
- if #numbers < 3 then
257
- return false, nil, 'expected three 0-1 components, e.g. "1, 0.5, 0"'
258
- end
259
- -- 0-255 is a common mistake and unambiguous to detect, so accept it.
260
- local scale = if numbers[1] > 1 or numbers[2] > 1 or numbers[3] > 1 then 255 else 1
261
- return true, Color3.new(numbers[1] / scale, numbers[2] / scale, numbers[3] / scale), nil
262
- end
263
- if valueType == "BrickColor" then
264
- -- Cast because the API definitions type this overload as a literal union
265
- -- of every BrickColor name. At runtime it takes any string and throws on
266
- -- an unknown one, which is exactly what the pcall is here to catch.
267
- local ok, color = pcall(function()
268
- return BrickColor.new(tostring(text) :: any)
269
- end)
270
- if not ok then
271
- return false, nil, string.format('"%s" is not a BrickColor name', tostring(text))
272
- end
273
- return true, color, nil
274
- end
275
- if valueType == "UDim2" then
276
- if #numbers < 4 then
277
- return false, nil, 'expected four numbers, e.g. "{0.5, 10}, {0.5, 20}"'
278
- end
279
- return true, UDim2.new(numbers[1], numbers[2], numbers[3], numbers[4]), nil
280
- end
281
- if valueType == "UDim" then
282
- if #numbers < 2 then
283
- return false, nil, 'expected two numbers, e.g. "{0.5, 10}"'
284
- end
285
- return true, UDim.new(numbers[1], numbers[2]), nil
286
- end
287
- if valueType == "CFrame" then
288
- if #numbers >= 6 then
289
- return true,
290
- CFrame.new(numbers[1], numbers[2], numbers[3])
291
- * CFrame.fromOrientation(
292
- math.rad(numbers[4]),
293
- math.rad(numbers[5]),
294
- math.rad(numbers[6])
295
- ),
296
- nil
297
- end
298
- if #numbers >= 3 then
299
- return true, CFrame.new(numbers[1], numbers[2], numbers[3]), nil
300
- end
301
- return false, nil, 'expected "pos x, y, z" or "pos x, y, z | rot rx, ry, rz"'
302
- end
303
- if valueType == "NumberRange" then
304
- if #numbers < 2 then
305
- return false, nil, 'expected "min..max"'
306
- end
307
- return true, NumberRange.new(numbers[1], numbers[2]), nil
308
- end
309
-
310
- -- Enum values arrive as "Enum.Material.Plastic" or bare "Plastic".
311
- local enumType = (Enum :: any)[valueType]
312
- if enumType then
313
- local name = tostring(text):match("([^%.]+)$") or tostring(text)
314
- for _, item in enumType:GetEnumItems() do
315
- if item.Name == name then
316
- return true, item, nil
317
- end
318
- end
319
- return false,
320
- nil,
321
- string.format('"%s" is not a member of Enum.%s', tostring(text), valueType)
322
- end
323
-
324
- return false, nil, string.format('no conversion known for type "%s"', valueType)
325
- end
326
-
327
- 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
+ -- 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