--!strict --[[ Converts Roblox values into JSON-safe, token-cheap representations. Format mirrors what the Studio Properties panel shows -- Vector3 as "12, 0, 5", Enums as "Enum.Material.Plastic", instance references as their path. Two reasons: a Roblox developer reading the transcript sees what they would see in Studio, and a model has seen far more of that notation in training than any bespoke JSON envelope. It is also markedly cheaper than {"__type":"Vector3","x":12,"y":0,"z":5} on every property of every instance. `Serialize.parse` is the exact inverse, so a value read here can be written straight back without the agent reformatting it. ]] local Paths = require(script.Parent.Paths) local Serialize = {} --[[ Marks a failure that means "the instance is not there" rather than "this value is wrong". The create handler retries only these, once the whole batch is attached, so a reference to a sibling made in the same call resolves. Anything else is a real mistake and is reported straight away. ]] local UNRESOLVED_PREFIX = "[unresolved] " Serialize.UNRESOLVED_PREFIX = UNRESOLVED_PREFIX -- Floats are rounded before display: Studio reports positions like -- 12.000000476837158, which costs tokens and means nothing to the caller. local PRECISION = 4 -- API-dump type names that accept a plain Luau number. local NUMERIC_TYPES: { [string]: boolean } = { int = true, int64 = true, float = true, double = true, number = true, } local function num(value: number): string local rounded = math.round(value * 10 ^ PRECISION) / 10 ^ PRECISION if rounded == math.floor(rounded) and math.abs(rounded) < 1e15 then return string.format("%d", rounded) end return tostring(rounded) end --[[ Serializes one value. Unknown userdata falls back to `tostring`, which is always better than dropping the property silently. ]] function Serialize.value(value: any): any local kind = typeof(value) if kind == "string" or kind == "boolean" or kind == "nil" then return value end if kind == "number" then -- JSON has no inf or nan, and HttpService:JSONEncode turns them into a -- `{"m":null,"t":"numeric","v":"inf"}` object nobody asked for. Written as -- text instead, in the same words `modify` accepts back. if value ~= value then return "nan" elseif value == math.huge then return "inf" elseif value == -math.huge then return "-inf" end -- Kept numeric so the agent can do arithmetic without parsing. return math.round(value * 10 ^ PRECISION) / 10 ^ PRECISION end if kind == "Vector3" then return string.format("%s, %s, %s", num(value.X), num(value.Y), num(value.Z)) end if kind == "Vector2" then return string.format("%s, %s", num(value.X), num(value.Y)) end if kind == "CFrame" then local position = value.Position local rx, ry, rz = value:ToOrientation() return string.format( "pos %s, %s, %s | rot %s, %s, %s", num(position.X), num(position.Y), num(position.Z), num(math.deg(rx)), num(math.deg(ry)), num(math.deg(rz)) ) end if kind == "Color3" then return string.format("%s, %s, %s", num(value.R), num(value.G), num(value.B)) end if kind == "BrickColor" then return value.Name end if kind == "UDim2" then return string.format( "{%s, %s}, {%s, %s}", num(value.X.Scale), num(value.X.Offset), num(value.Y.Scale), num(value.Y.Offset) ) end if kind == "UDim" then return string.format("{%s, %s}", num(value.Scale), num(value.Offset)) end if kind == "Font" then --[[ Written so it can be read back, which `tostring` is not. A Font stringifies as `Font { Family = rbxasset://..., Weight = Bold, Style = Normal }`, which nothing here can parse. The family, weight and style are all there is to one, so they are written plainly and separated the way a composite value is elsewhere in this module. ]] return string.format( "%s | %s | %s", value.Family, value.Weight.Name, value.Style.Name ) end if kind == "EnumItem" then return string.format("Enum.%s.%s", tostring(value.EnumType), value.Name) end if kind == "Content" then --[[ Written as the bare URI so it can be read back. `tostring` on a Content gives `Content{SourceType=Uri, Uri=rbxassetid://123}`, which `parse` cannot take. A Content that holds an Object or no source at all has no URI to give, so those are named rather than rendered as an empty string that would read as "cleared". ]] if value.Uri then return value.Uri end if value.Object then return Paths.of(value.Object) end return "" end if kind == "Instance" then --[[ Formatted as an address, not as a display name. `GetFullName` writes "StarterPack.Pistol.Handle" for both of two Tools named Pistol, so reading a Motor6D's Part1 and writing it back pointed at the wrong one -- silently, since both paths resolve. `Paths.of` adds the [n] that tells them apart, which is what makes a value read here safe to write straight back. ]] return Paths.of(value) end if kind == "NumberRange" then return string.format("%s..%s", num(value.Min), num(value.Max)) end if kind == "Rect" then return string.format( "%s, %s, %s, %s", num(value.Min.X), num(value.Min.Y), num(value.Max.X), num(value.Max.Y) ) end if kind == "ColorSequence" then local parts: { string } = {} for _, keypoint in value.Keypoints do table.insert( parts, string.format("%s=%s", num(keypoint.Time), Serialize.value(keypoint.Value)) ) end return table.concat(parts, " ") end if kind == "NumberSequence" then local parts: { string } = {} for _, keypoint in value.Keypoints do table.insert(parts, string.format("%s=%s", num(keypoint.Time), num(keypoint.Value))) end return table.concat(parts, " ") end if kind == "table" then local copy: { [any]: any } = {} for key, item in value :: { [any]: any } do copy[key] = Serialize.value(item) end return copy end return tostring(value) end --[[ Reads a property without throwing. Property access on an instance can error even when the API dump says the property exists -- some are context-gated (only readable during a playtest, or only on a loaded asset). Returning nil for those keeps one awkward property from failing an inspect of 50 instances. ]] function Serialize.readProperty(instance: Instance, name: string): (boolean, any) local ok, value = pcall(function() return (instance :: any)[name] end) if not ok then return false, nil end return true, Serialize.value(value) end --[[ Pulls the numbers out of a composite value written as text. This used to be a single `-?%d+%.?%d*` scan, which is not the grammar Luau numbers actually have: "1e3, 0, .5" came back as 1, 3 and 0, so a Position written in scientific notation was silently set to a different point in the world and reported as applied. Splitting on the separators and handing each token to `tonumber` means this agrees with the scalar path, which always used `tonumber` -- the two disagreeing about what counts as a number was the whole bug. Non-numeric tokens are dropped, so "Vector3.new(1, 2, 3)" and "{1, 2, 3}" still read as three numbers. ]] local function scanTokens(text: string): { number } local numbers: { number } = {} for token in string.gmatch(text, "[^,%s%(%)%[%]{}<>]+") do local value = tonumber(token) if value then table.insert(numbers, value) end end return numbers end --[[ ColorSequence and NumberSequence. `Serialize.value` has always WRITTEN these -- "0=1, 0, 0 1=0, 0, 1" for a red-to-blue gradient -- and `parse` had no branch for either, so every one of them could be read and none could be written back. That is not a corner: ColorSequence and NumberSequence are what ParticleEmitter, Beam, Trail and UIGradient are made of, so recolouring any effect fell out of `modify` entirely and needed `execute_luau`. Found by auditing the round trip rather than by hitting it. Keypoints are split on the "