-- Generated by studio-plugin/build_v9.py; edit src/Standalone.lua, src/V9Tools.lua or the modules. --[[ Rateblox — AI agent for Roblox Studio (https://rateblox.com) 1. Click "Rateblox AI" in the Plugins tab. 2. Get a pairing code and enter it at rateblox.com/app/plugin. 3. Chat with the agent in this panel (or on the website). The agent reads the open place, builds, writes scripts and playtests through the tools below; each result is sent back to rateblox.com. Network: talks only to the server set in Settings (rateblox.com). Safety: every change is a normal undo step; scripts inside inserted Creator Store models are removed. ]] local PLUGIN_VERSION = 11 local ChangeHistoryService = game:GetService("ChangeHistoryService") local HttpService = game:GetService("HttpService") local LogService = game:GetService("LogService") local RunService = game:GetService("RunService") local Selection = game:GetService("Selection") -- Play-mode frame: the edit session's capture shows the editor camera, not -- the game (seen 2026-10-18), and a LocalScript can't read its own capture. -- The plugin copy that loads inside the play client can: when a playtest -- asked for a screenshot (ShotAt on the test remote), it captures the -- player's screen, reads the pixels and sends them to the test runner in -- parts; the runner hands the frame back through EndTest. if RunService:IsClient() and RunService:IsRunning() and not RunService:IsServer() then task.spawn(function() local remote = game:GetService("ReplicatedStorage"):WaitForChild("__RatebloxTestLog", 20) local shotAt = remote and tonumber(remote:GetAttribute("ShotAt")) if not shotAt then return end local width = math.clamp(tonumber(remote:GetAttribute("ShotWidth")) or 640, 160, 768) local elapsed = os.time() - (tonumber(remote:GetAttribute("Start")) or os.time()) task.wait(math.max(0.5, shotAt - elapsed)) local ok, err = pcall(function() local contentId game:GetService("CaptureService"):CaptureScreenshot(function(id) contentId = id end) local deadline = os.clock() + 8 while not contentId and os.clock() < deadline do task.wait(0.05) end assert(contentId, "the capture did not finish") -- The play client may not read its own capture ("temporary texture id"); -- then the edit session reads it: the id goes over the plugin's shared -- settings with this playtest's token. local okImage, image = pcall(function() return game:GetService("AssetService"):CreateEditableImageAsync(Content.fromUri(contentId)) end) if not okImage then plugin:SetSetting("RatebloxPlayCapture", tostring(remote:GetAttribute("ShotToken")) .. "|" .. contentId) return end local size = image.Size local pixels = image:ReadPixelsBuffer(Vector2.zero, size) image:Destroy() local step = math.max(1, size.X / width) local w, h = math.floor(size.X / step), math.floor(size.Y / step) local rgb = buffer.create(w * h * 3) for y = 0, h - 1 do local row = math.floor(y * step) * size.X for x = 0, w - 1 do local from, to = (row + math.floor(x * step)) * 4, (y * w + x) * 3 buffer.writeu8(rgb, to, buffer.readu8(pixels, from)) buffer.writeu8(rgb, to + 1, buffer.readu8(pixels, from + 1)) buffer.writeu8(rgb, to + 2, buffer.readu8(pixels, from + 2)) end if y % 64 == 0 then task.wait() end end -- base64 written straight into a buffer (string building per byte was too slow for a frame). local codes = buffer.create(64) for i, letter in ipairs(string.split("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", "")) do buffer.writeu8(codes, i - 1, string.byte(letter)) end local length = buffer.len(rgb) local out = buffer.create(math.ceil(length / 3) * 4) local o = 0 for i = 0, length - 1, 3 do local a = buffer.readu8(rgb, i) local b = i + 1 < length and buffer.readu8(rgb, i + 1) or 0 local c = i + 2 < length and buffer.readu8(rgb, i + 2) or 0 local n = a * 65536 + b * 256 + c buffer.writeu8(out, o, buffer.readu8(codes, bit32.rshift(n, 18))) buffer.writeu8(out, o + 1, buffer.readu8(codes, bit32.band(bit32.rshift(n, 12), 63))) buffer.writeu8(out, o + 2, i + 1 < length and buffer.readu8(codes, bit32.band(bit32.rshift(n, 6), 63)) or 61) buffer.writeu8(out, o + 3, i + 2 < length and buffer.readu8(codes, bit32.band(n, 63)) or 61) o += 4 end local encoded = buffer.tostring(out) local CHUNK = 60000 local count = math.ceil(#encoded / CHUNK) remote:FireServer("__shot_begin", w, h, count) for index = 1, count do remote:FireServer("__shot_part", index, encoded:sub((index - 1) * CHUNK + 1, index * CHUNK)) task.wait() end end) if not ok then remote:FireServer("__shot_error", tostring(err)) end end) end -- Plugins also load inside playtest DataModels; only the edit session talks -- to the website (otherwise a playtest would steal the agent's tool calls). if not RunService:IsEdit() then return end local StudioService do local ok, service = pcall(function() return game:GetService("StudioService") end) if ok then StudioService = service end end -- GenerationService is only present in Studio builds where Cube 3D is enabled. -- We look it up lazily and gracefully report "not available" when missing so -- the plugin still works for primitive builds. local GenerationService do local ok, service = pcall(function() return game:GetService("GenerationService") end) if ok then GenerationService = service end end -- Exposed to build scripts as _G.RatebloxInsertAsset so that inserting a -- Creator Store model AND removing any scripts inside it is always done by -- plugin code — not by whatever Lua the agent writes. -- Signature: _G.RatebloxInsertAsset(assetId, { -- name?, size?, cframe?, parent?, trusted?, anchor? -- }) -> Instance|nil. Scripts are stripped unless trusted == true. Parts are -- anchored unless anchor == false. local function insertAssetSafe(assetId, opts) opts = typeof(opts) == "table" and opts or {} assetId = tonumber(assetId) if not assetId or assetId <= 0 then return nil end -- game:GetObjects loads a public Creator Store model, like Toolbox does. local root local okObjects, objects = pcall(function() return game:GetObjects("rbxassetid://" .. assetId) end) if okObjects and typeof(objects) == "table" and #objects > 0 then if #objects == 1 then root = objects[1] else root = Instance.new("Model") root.Name = "Asset" .. assetId for _, object in ipairs(objects) do object.Parent = root end end else return nil end -- Remove every script unless the caller vouches for the source (our own -- assets whose scripts are the game logic). if opts.trusted ~= true then for _, d in ipairs(root:GetDescendants()) do if d:IsA("LuaSourceContainer") then d:Destroy() end end end if typeof(opts.name) == "string" and opts.name ~= "" then root.Name = opts.name end if root:IsA("Model") and typeof(opts.size) == "number" and opts.size > 0 then local e = root:GetExtentsSize() local m = math.max(e.X, e.Y, e.Z) if m > 0.01 then pcall(function() root:ScaleTo(opts.size / m) end) end end local parent = opts.parent if typeof(parent) ~= "Instance" then parent = workspace end root.Parent = parent if typeof(opts.cframe) == "CFrame" then if root:IsA("Model") then pcall(function() root:PivotTo(opts.cframe) end) elseif root:IsA("BasePart") then root.CFrame = opts.cframe end end if opts.anchor ~= false then for _, d in ipairs(root:GetDescendants()) do if d:IsA("BasePart") then d.Anchored = true end end if root:IsA("BasePart") then root.Anchored = true end end return root end _G.RatebloxInsertAsset = insertAssetSafe local DEFAULT_API = "https://rateblox.com" -- Poll fast while the agent is working (it waits on every tool result), slow -- when idle. Each poll is one cheap request. -- Fast while the agent works, slower when idle, slowest after a quiet spell. -- The server says when an agent run is in progress (busy) and how long to -- wait when idle, so pacing can change without a plugin update. local POLL_ACTIVE_SECONDS = 0.8 local POLL_IDLE_SECONDS = 4 local POLL_QUIET_SECONDS = 8 local ACTIVE_WINDOW_SECONDS = 120 local QUIET_AFTER_SECONDS = 600 local PAIR_POLL_SECONDS = 1.5 local toolbar = plugin:CreateToolbar("Rateblox") local toolbarButton = toolbar:CreateButton( "Rateblox AI", "Chat with the Rateblox AI agent — it builds, scripts and playtests in this place", "" ) toolbarButton.ClickableWhenViewportHidden = true local widgetInfo = DockWidgetPluginGuiInfo.new( Enum.InitialDockState.Right, false, false, 380, 640, 320, 440 ) local widget = plugin:CreateDockWidgetPluginGui("RatebloxDock", widgetInfo) widget.Title = "Rateblox" widget.Name = "Rateblox" local state = { apiBase = plugin:GetSetting("ApiBase") or DEFAULT_API, code = plugin:GetSetting("PairCode"), connected = false, busy = false, logLines = {}, pendingLogs = {}, features = { logs = false, health = false, passes = false, }, } local running = true plugin.Unloading:Connect(function() running = false end) local function trim(text) return (tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", "")) end local function apiUrl(path) local base = trim(state.apiBase):gsub("/+$", "") return base .. path end local function log(message) table.insert(state.logLines, 1, os.date("%H:%M:%S") .. " " .. message) while #state.logLines > 12 do table.remove(state.logLines) end if state.refresh then state.refresh() end end -- Code runner ------------------------------------------------------------ -- Runs Luau from the agent using only Creator Store-safe APIs: the code -- becomes the body of a function in a -- fresh, unparented ModuleScript that this plugin requires. print/warn are -- shadowed by locals so the agent sees its own output. local MODULE_HEADER = "local __rb = _G.__RatebloxRunHooks\nlocal print, warn = __rb.print, __rb.warn\nreturn function(...)\n" local MODULE_HEADER_LINES = 3 local runCounter = 0 local function joinArgs(...) local packed = table.pack(...) for i = 1, packed.n do packed[i] = tostring(packed[i]) end return table.concat(packed, " ", 1, packed.n) end -- "RatebloxRun7:12: msg" -> ":9: msg" (body line numbers) local function fixChunkLines(text, moduleName, chunkName) text = tostring(text) text = text:gsub(moduleName .. ":(%d+)", function(line) return chunkName .. ":" .. tostring(math.max(1, tonumber(line) - MODULE_HEADER_LINES)) end) return (text:gsub("[%w_%.]*" .. moduleName, chunkName)) end -- Compiles `source` as a function body. Returns fn or nil, error. Runs nothing -- but the module's top level (which only builds the function). local function compileChunk(source, chunkName, hooks) runCounter += 1 local moduleName = "RatebloxRun" .. runCounter local module = Instance.new("ModuleScript") module.Name = moduleName module.Source = MODULE_HEADER .. source .. "\nend\n" _G.__RatebloxRunHooks = hooks or { print = print, warn = warn } local logged = {} local connection = LogService.MessageOut:Connect(function(message, messageType) if messageType == Enum.MessageType.MessageError and message:find(moduleName, 1, true) then table.insert(logged, message) end end) local ok, result = pcall(require, module) if not ok and #logged == 0 and not tostring(result):find("error while loading", 1, true) then -- Some Studio builds only require modules that sit in the DataModel: -- retry from CoreGui (never saved with the place, not in undo history). local okParent = pcall(function() module.Parent = game:GetService("CoreGui") end) if okParent then ok, result = pcall(require, module) end end task.wait() -- let the engine flush the load error to the log connection:Disconnect() module:Destroy() if ok and typeof(result) == "function" then return result, nil, moduleName end local detail = logged[1] or tostring(result) return nil, fixChunkLines(detail, moduleName, chunkName), moduleName end -- Runs code; returns ok, returnValueOrError, outputLines. local function runCode(source, chunkName) local output = {} local size = 0 local function push(line) if size > 12000 then return end size += #line table.insert(output, line) end local hooks = { print = function(...) push(joinArgs(...)) end, warn = function(...) push("[warn] " .. joinArgs(...)) end, } local fn, compileError, moduleName = compileChunk(source, chunkName, hooks) if not fn then return false, "Compile error: " .. tostring(compileError), output end local ok, result = xpcall(fn, function(e) return tostring(e) .. "\n" .. debug.traceback(nil, 2) end) if not ok then result = fixChunkLines(result, moduleName, chunkName) end return ok, result, output end local DENY_PROPERTIES = { Parent = true, ClassName = true, UniqueId = true, DataCost = true, RobloxLocked = true, ClassSecurity = true, } local VECTOR3_PROPERTIES = { Size = true, Position = true, Orientation = true, Velocity = true, RotVelocity = true, AssemblyLinearVelocity = true, AssemblyAngularVelocity = true, Scale = true, } local ENUM_BY_PROPERTY = { Shape = "PartType", Material = "Material", TopSurface = "SurfaceType", BottomSurface = "SurfaceType", LeftSurface = "SurfaceType", RightSurface = "SurfaceType", FrontSurface = "SurfaceType", BackSurface = "SurfaceType", Face = "NormalId", MeshType = "MeshType", Technology = "Technology", } local COLOR3_PROPERTIES = { Color = true, Color3 = true, BackgroundColor3 = true, TextColor3 = true, BorderColor3 = true, ImageColor3 = true, Ambient = true, OutdoorAmbient = true, FogColor = true, ColorShift_Top = true, ColorShift_Bottom = true, } local function asNumberList(value) if typeof(value) == "table" then local list = {} for i = 1, 12 do local item = value[i] or value[tostring(i)] if item == nil then break end list[i] = tonumber(item) end return list end return nil end local function coerceValue(instance, key, value) if VECTOR3_PROPERTIES[key] then local list = asNumberList(value) if list and #list >= 3 then return Vector3.new(list[1], list[2], list[3]) end end if COLOR3_PROPERTIES[key] then local list = asNumberList(value) if list and #list >= 3 then local r, g, b = list[1], list[2], list[3] if r > 1 or g > 1 or b > 1 then return Color3.fromRGB(r, g, b) end return Color3.new(r, g, b) end end if key == "CFrame" then local list = asNumberList(value) if list then if #list >= 12 then return CFrame.new(unpack(list)) elseif #list >= 3 then return CFrame.new(list[1], list[2], list[3]) end end end if key == "BrickColor" and typeof(value) == "string" then local ok, brick = pcall(BrickColor.new, value) if ok then return brick end end if typeof(value) == "string" then local mapped = ENUM_BY_PROPERTY[key] or key local enumRoot = Enum[mapped] if enumRoot then local ok, item = pcall(function() return enumRoot[value] end) if ok and item then return item end end end return value end local function applyProperties(instance, properties) if typeof(properties) ~= "table" then return end for key, value in pairs(properties) do if typeof(key) == "string" and not DENY_PROPERTIES[key] then local ok, err = pcall(function() instance[key] = coerceValue(instance, key, value) end) if not ok then log("skip " .. key .. ": " .. tostring(err)) end end end end local function resolvePath(path) if typeof(path) ~= "string" or path == "" or path == "game" then return game end local segments = string.split(path, ".") local current = game local startIndex = 1 if segments[1] == "game" then startIndex = 2 end for i = startIndex, #segments do local name = segments[i] local child = current:FindFirstChild(name) if not child and current == game then local ok, service = pcall(function() return game:GetService(name) end) if ok then child = service end end if not child then return nil end current = child end return current end local function beginUndo(label) local ok, recording = pcall(function() return ChangeHistoryService:TryBeginRecording(label) end) if ok then return recording end return nil end local function finishUndo(recording) if recording then pcall(function() ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit) end) else pcall(function() ChangeHistoryService:SetWaypoint("Rateblox") end) end end local function applyCommand(command) if typeof(command) ~= "table" or typeof(command.op) ~= "string" then return false, "invalid command" end if command.op == "create" then local parent = resolvePath(command.parent or "Workspace") or workspace local ok, instance = pcall(Instance.new, command.className) if not ok or not instance then return false, "cannot create " .. tostring(command.className) end instance.Name = command.name or command.className if instance:IsA("BasePart") then instance.Anchored = true instance.TopSurface = Enum.SurfaceType.Smooth instance.BottomSurface = Enum.SurfaceType.Smooth instance.LeftSurface = Enum.SurfaceType.Smooth instance.RightSurface = Enum.SurfaceType.Smooth instance.FrontSurface = Enum.SurfaceType.Smooth instance.BackSurface = Enum.SurfaceType.Smooth end applyProperties(instance, command.properties) if instance:IsA("LuaSourceContainer") then local source = command.source or (command.properties and command.properties.Source) if typeof(source) == "string" and source ~= "" then instance.Source = source end end instance.Parent = parent if typeof(command.children) == "table" then for _, child in ipairs(command.children) do if typeof(child) == "table" then child.op = child.op or "create" child.parent = instance:GetFullName() applyCommand(child) end end end return true, instance:GetFullName() end if command.op == "update" then local instance = resolvePath(command.path) if not instance then return false, "missing " .. tostring(command.path) end applyProperties(instance, command.properties) return true, instance:GetFullName() end if command.op == "setSource" then local instance = resolvePath(command.path) if not instance then return false, "missing " .. tostring(command.path) end if not instance:IsA("LuaSourceContainer") then return false, "not a script: " .. instance.ClassName end instance.Source = tostring(command.source or "") return true, instance:GetFullName() end if command.op == "destroy" then local instance = resolvePath(command.path) if not instance then return false, "missing " .. tostring(command.path) end local name = instance:GetFullName() instance:Destroy() return true, name end if command.op == "select" then local instance = resolvePath(command.path) if not instance then return false, "missing " .. tostring(command.path) end Selection:Set({ instance }) return true, instance:GetFullName() end if command.op == "insertAsset" then local assetId = tonumber(command.assetId) if not assetId or assetId <= 0 then return false, "insertAsset: missing assetId" end local parent = resolvePath(command.parent or "Workspace") or workspace local cframe local pos = asNumberList(command.position) if pos and #pos >= 3 then cframe = CFrame.new(pos[1], pos[2], pos[3]) local orient = asNumberList(command.orientation) if orient and #orient >= 3 then cframe *= CFrame.Angles(math.rad(orient[1]), math.rad(orient[2]), math.rad(orient[3])) end end local root = insertAssetSafe(assetId, { name = command.name, parent = parent, cframe = cframe, trusted = command.sanitize ~= true and command.trusted == true, anchor = command.anchor ~= false, }) if not root then return false, "insertAsset: could not load " .. assetId end return true, root:GetFullName() end if command.op == "executeLuau" then local src = tostring(command.source or "") if src == "" then return false, "executeLuau: empty source" end log("executeLuau (" .. tostring(#src) .. " chars)") local ok, result = runCode(src, "RatebloxBuild") if not ok then return false, "executeLuau: " .. tostring(result) end if typeof(result) == "string" and result ~= "" then log("lua: " .. result:sub(1, 120)) return true, "ok: " .. result:sub(1, 80) end return true, "executed" end if command.op == "promptPublish" then local url = tostring(command.url or "") log("Publish this place: File → Publish to Roblox") if url ~= "" then pcall(function() game:GetService("GuiService"):OpenBrowserWindow(url) end) end return true, "publish prompted" end if command.op == "playtest" then return true, "playtest" end if command.op == "generateModel" then if not GenerationService then return false, "GenerationService unavailable (enable Editable Mesh API in Game Settings → Security)" end local prompt = tostring(command.prompt or "") if prompt == "" then return false, "generateModel: empty prompt" end local parent = resolvePath(command.parent or "Workspace") or workspace local inputs = { TextPrompt = prompt } local sizeList = asNumberList(command.size) if sizeList and #sizeList >= 3 then inputs.Size = Vector3.new(sizeList[1], sizeList[2], sizeList[3]) end if typeof(command.maxTriangles) == "number" and command.maxTriangles > 0 then inputs.MaxTriangles = math.floor(command.maxTriangles) end if command.generateTextures == false then inputs.GenerateTextures = false end local schema if typeof(command.groups) == "table" and #command.groups > 0 then schema = { SchemaDefinition = { Groups = command.groups } } else local predefined = tostring(command.schema or "Body1") schema = { PredefinedSchema = predefined } end log("generateModel: " .. prompt:sub(1, 40)) local ok, model = pcall(function() return GenerationService:GenerateModelAsync(inputs, schema) end) if not ok or typeof(model) ~= "Instance" then return false, "generateModel failed: " .. tostring(model) end -- Anchor all parts so the model doesn't fall apart. for _, descendant in ipairs(model:GetDescendants()) do if descendant:IsA("BasePart") then descendant.Anchored = true end end if typeof(command.name) == "string" and command.name ~= "" then model.Name = command.name end -- Scale to requested extents if provided. if sizeList and #sizeList >= 3 then local target = math.max(sizeList[1], sizeList[2], sizeList[3]) local extents = model:GetExtentsSize() local maxExtent = math.max(extents.X, extents.Y, extents.Z) if maxExtent > 0.01 and target > 0.01 then pcall(function() model:ScaleTo(target / maxExtent) end) end end local extents = model:GetExtentsSize() local halfY = extents.Y / 2 local posList = asNumberList(command.position) if posList and #posList >= 3 then model:PivotTo(CFrame.new(posList[1], posList[2], posList[3])) else model:PivotTo(CFrame.new(0, halfY, 0)) end model.Parent = parent return true, model:GetFullName() end return false, "unknown op " .. command.op end -- Agent tools ------------------------------------------------------------- -- -- The website's agent loop sends { tool, args } calls. Each handler returns -- (ok, outputText); the plugin posts that back to /api/plugin/results and the -- model reads it on its next step. Outputs are plain text written for the -- model: compact, with paths it can copy into the next call. local CollectionService = game:GetService("CollectionService") local ScriptEditorService do local ok, service = pcall(function() return game:GetService("ScriptEditorService") end) if ok then ScriptEditorService = service end end local MAIN_SERVICES = { "Workspace", "ReplicatedStorage", "ReplicatedFirst", "ServerScriptService", "ServerStorage", "StarterGui", "StarterPack", "StarterPlayer", "Lighting", "SoundService", "Teams", } local TEST_PREFIX = "__RatebloxTest" local function round(n) return math.floor(n * 10 + 0.5) / 10 end local function fmtVector(v) return string.format("(%s, %s, %s)", round(v.X), round(v.Y), round(v.Z)) end local function fmtValue(value) local kind = typeof(value) if kind == "Vector3" then return fmtVector(value) elseif kind == "Vector2" then return string.format("(%s, %s)", round(value.X), round(value.Y)) elseif kind == "CFrame" then local rx, ry, rz = value:ToOrientation() return string.format( "pos %s rot (%s, %s, %s)", fmtVector(value.Position), round(math.deg(rx)), round(math.deg(ry)), round(math.deg(rz)) ) elseif kind == "Color3" then return string.format( "rgb(%d, %d, %d)", math.floor(value.R * 255 + 0.5), math.floor(value.G * 255 + 0.5), math.floor(value.B * 255 + 0.5) ) elseif kind == "UDim2" then return string.format( "{%s, %d}, {%s, %d}", round(value.X.Scale), value.X.Offset, round(value.Y.Scale), value.Y.Offset ) elseif kind == "UDim" then return string.format("{%s, %d}", round(value.Scale), value.Offset) elseif kind == "EnumItem" then return tostring(value) elseif kind == "Instance" then return value:GetFullName() elseif kind == "BrickColor" then return value.Name elseif kind == "number" then return tostring(round(value)) elseif kind == "string" then if #value > 120 then return string.format("%q", value:sub(1, 120) .. "…") end return string.format("%q", value) end return tostring(value) end local function splitPath(path) path = tostring(path or ""):gsub("/", ".") path = path:gsub("^%s+", ""):gsub("%s+$", "") local segments = {} for _, segment in ipairs(string.split(path, ".")) do if segment ~= "" then table.insert(segments, segment) end end if segments[1] == "game" then table.remove(segments, 1) end return segments end local function childOf(parent, name) local child = parent:FindFirstChild(name) if not child and parent == game then local ok, service = pcall(function() return game:GetService(name) end) if ok then child = service end end return child end local function findPath(path) local segments = splitPath(path) local current = game for i, name in ipairs(segments) do local child = childOf(current, name) if not child then local partial = table.concat(segments, ".", 1, i - 1) return nil, string.format( 'Not found: "%s" has no child "%s". Use get_tree on "%s" to see what exists.', partial == "" and "game" or partial, name, partial == "" and "game" or partial ) end current = child end return current end local function pathOf(inst) local full = inst:GetFullName() return full end local function lineCount(source) if source == "" then return 0 end return #string.split(source, "\n") end local function describe(inst) local text = inst.Name .. " [" .. inst.ClassName .. "]" if inst:IsA("LuaSourceContainer") then local ok, source = pcall(function() return inst.Source end) if ok then text ..= " " .. lineCount(source) .. " lines" end if inst:IsA("BaseScript") and inst.Disabled then text ..= " DISABLED" end elseif inst:IsA("BasePart") then text ..= " size " .. fmtVector(inst.Size) .. " at " .. fmtVector(inst.Position) elseif inst:IsA("Model") then local ok, size = pcall(function() return inst:GetExtentsSize() end) if ok then text ..= " bbox " .. fmtVector(size) .. " pivot " .. fmtVector(inst:GetPivot().Position) end elseif inst:IsA("ValueBase") then local ok, value = pcall(function() return (inst :: any).Value end) if ok then text ..= " = " .. fmtValue(value) end elseif inst:IsA("TextLabel") or inst:IsA("TextButton") or inst:IsA("TextBox") then text ..= " text " .. fmtValue((inst :: any).Text) end local tags = CollectionService:GetTags(inst) if #tags > 0 then text ..= " tags{" .. table.concat(tags, ",") .. "}" end local attrs = inst:GetAttributes() local attrText = {} for key, value in pairs(attrs) do table.insert(attrText, key .. "=" .. fmtValue(value)) if #attrText >= 6 then break end end if #attrText > 0 then text ..= " attrs{" .. table.concat(attrText, ", ") .. "}" end return text end local MAX_TREE_LINES = 450 local MAX_CHILDREN = 40 local function treeLines(root, depth, lines, indent) if #lines >= MAX_TREE_LINES then return end local children = root:GetChildren() local shown = 0 local skipped = {} for _, child in ipairs(children) do if child.Name:sub(1, #TEST_PREFIX) == TEST_PREFIX then continue end if child:IsA("Camera") or child:IsA("Terrain") then continue end if shown >= MAX_CHILDREN or #lines >= MAX_TREE_LINES then skipped[child.ClassName] = (skipped[child.ClassName] or 0) + 1 continue end shown += 1 local childCount = #child:GetChildren() local suffix = "" if depth <= 1 and childCount > 0 then suffix = " (+" .. childCount .. " children)" end table.insert(lines, indent .. describe(child) .. suffix) if depth > 1 then treeLines(child, depth - 1, lines, indent .. " ") end end local summary = {} for className, count in pairs(skipped) do table.insert(summary, count .. " " .. className) end if #summary > 0 then table.insert(lines, indent .. "… more: " .. table.concat(summary, ", ")) end end local function toolGetTree(args) local depth = math.clamp(tonumber(args.depth) or 3, 1, 6) local lines = {} local path = args.path if path == nil or path == "" or path == "game" then for _, serviceName in ipairs(MAIN_SERVICES) do local ok, service = pcall(function() return game:GetService(serviceName) end) if ok and service then table.insert(lines, serviceName .. " [" .. service.ClassName .. "]") treeLines(service, depth - 1, lines, " ") end end else local root, err = findPath(path) if not root then return false, err end table.insert(lines, pathOf(root) .. " " .. describe(root)) treeLines(root, depth, lines, " ") end if #lines >= MAX_TREE_LINES then table.insert(lines, "[output capped — call get_tree on a deeper path]") end return true, table.concat(lines, "\n") end local INSPECT_PROPERTIES = { "Size", "Position", "Orientation", "CFrame", "Color", "Material", "Transparency", "Reflectance", "Anchored", "CanCollide", "CanTouch", "CanQuery", "Massless", "Shape", "MeshId", "TextureID", "PrimaryPart", "WorldPivot", "Value", "Enabled", "Disabled", "RunContext", "Text", "TextColor3", "TextSize", "TextScaled", "Font", "Image", "AnchorPoint", "Visible", "BackgroundColor3", "BackgroundTransparency", "ZIndex", "LayoutOrder", "ResetOnSpawn", "IgnoreGuiInset", "Adornee", "MaxDistance", "Brightness", "Range", "Angle", "Face", "Health", "MaxHealth", "WalkSpeed", "JumpPower", "RigType", "DisplayName", "ActionText", "ObjectText", "HoldDuration", "KeyboardKeyCode", "RequiresLineOfSight", "SoundId", "Volume", "Looped", "Playing", "Texture", "Duration", "ClockTime", "Ambient", "OutdoorAmbient", "FogEnd", "FogColor", "EnvironmentDiffuseScale", "Density", "Offset", "Intensity", "Saturation", "Contrast", "TintColor", "Neutral", "TeamColor", "AllowTeamChangeOnTouch", "Duration", "Part0", "Part1", "C0", "C1", "Attachment0", "Attachment1", "Gravity", "StreamingEnabled", } local function toolInspect(args) local inst, err = findPath(args.path) if not inst then return false, err end local lines = { pathOf(inst) .. " [" .. inst.ClassName .. "]" } local seen = {} for _, prop in ipairs(INSPECT_PROPERTIES) do if not seen[prop] then seen[prop] = true local ok, value = pcall(function() return (inst :: any)[prop] end) if ok and value ~= nil and typeof(value) ~= "function" and typeof(value) ~= "RBXScriptSignal" then if not (typeof(value) == "Instance" and value == inst) then table.insert(lines, " " .. prop .. " = " .. fmtValue(value)) end end end end if inst:IsA("Model") then local ok, cf, size = pcall(function() return inst:GetBoundingBox() end) if ok then table.insert(lines, " BoundingBox = center " .. fmtVector(cf.Position) .. " size " .. fmtVector(size)) end end local attrs = inst:GetAttributes() for key, value in pairs(attrs) do table.insert(lines, " @" .. key .. " = " .. fmtValue(value)) end local tags = CollectionService:GetTags(inst) if #tags > 0 then table.insert(lines, " tags: " .. table.concat(tags, ", ")) end local children = inst:GetChildren() if #children > 0 then table.insert(lines, " children (" .. #children .. "):") for i, child in ipairs(children) do if i > 60 then table.insert(lines, " …") break end table.insert(lines, " " .. describe(child)) end end return true, table.concat(lines, "\n") end local function readSource(inst) local ok, source = pcall(function() return inst.Source end) if not ok then return nil end if ScriptEditorService then local okDoc, docSource = pcall(function() return ScriptEditorService:GetEditorSource(inst) end) if okDoc and typeof(docSource) == "string" then return docSource end end return source end local function toolReadScript(args) local inst, err = findPath(args.path) if not inst then return false, err end if not inst:IsA("LuaSourceContainer") then return false, pathOf(inst) .. " is a " .. inst.ClassName .. ", not a script" end local source = readSource(inst) or "" local lines = string.split(source, "\n") local first = math.max(1, tonumber(args.start_line) or 1) local last = math.min(#lines, tonumber(args.end_line) or #lines) local out = { pathOf(inst) .. " [" .. inst.ClassName .. "] " .. #lines .. " lines" } for i = first, last do table.insert(out, string.format("%4d| %s", i, lines[i])) end return true, table.concat(out, "\n") end local function eachScript(root, fn) local roots = {} if root then table.insert(roots, root) else for _, serviceName in ipairs(MAIN_SERVICES) do local ok, service = pcall(function() return game:GetService(serviceName) end) if ok and service then table.insert(roots, service) end end end for _, r in ipairs(roots) do local list = r:GetDescendants() if r:IsA("LuaSourceContainer") then table.insert(list, 1, r) end for _, inst in ipairs(list) do if inst:IsA("LuaSourceContainer") and inst.Name:sub(1, #TEST_PREFIX) ~= TEST_PREFIX then if fn(inst) == false then return end end end end end local function toolGrepScripts(args) local pattern = tostring(args.pattern or "") if pattern == "" then return false, "pattern is required" end local root if args.path and args.path ~= "" and args.path ~= "game" then local err root, err = findPath(args.path) if not root then return false, err end end local needle = pattern:lower() local out = {} eachScript(root, function(inst) local source = readSource(inst) if not source then return end for number, line in ipairs(string.split(source, "\n")) do if line:lower():find(needle, 1, true) then table.insert(out, pathOf(inst) .. ":" .. number .. ": " .. line:sub(1, 160)) if #out >= 80 then return false end end end end) if #out == 0 then return true, 'No matches for "' .. pattern .. '".' end return true, table.concat(out, "\n") end local SCRIPT_CLASSES = { Script = true, LocalScript = true, ModuleScript = true } local function isUnder(inst, serviceName) local ok, service = pcall(function() return game:GetService(serviceName) end) return ok and service and inst:IsDescendantOf(service) end local function placementWarning(inst) if inst:IsA("LocalScript") then local starterPlayer = game:GetService("StarterPlayer") if not ( inst:IsDescendantOf(starterPlayer) or isUnder(inst, "StarterGui") or isUnder(inst, "StarterPack") or isUnder(inst, "ReplicatedFirst") ) then return "WARNING: a LocalScript here will NOT run. Put it in StarterPlayer.StarterPlayerScripts, StarterPlayer.StarterCharacterScripts, StarterGui or StarterPack." end elseif inst:IsA("Script") then local runContext = (inst :: any).RunContext if runContext == Enum.RunContext.Legacy or runContext == nil then if not (isUnder(inst, "ServerScriptService") or isUnder(inst, "Workspace")) then return "WARNING: a server Script here will NOT run. Put it in ServerScriptService (or Workspace)." end end end return nil end local function compileCheck(source, chunkName) local _, err = compileChunk(source, chunkName) return err end local function setSource(inst, source) if ScriptEditorService then local ok = pcall(function() ScriptEditorService:UpdateSourceAsync(inst, function() return source end) end) if ok then return end end inst.Source = source end local function ensureParent(segments) local current = game local created = {} for i = 1, #segments - 1 do local name = segments[i] local child = childOf(current, name) if not child then if current == game then return nil, "Unknown service " .. name end child = Instance.new("Folder") child.Name = name child.Parent = current table.insert(created, child:GetFullName()) end current = child end return current, created end local function toolWriteScript(args) local className = tostring(args.class_name or "") if not SCRIPT_CLASSES[className] then return false, "class_name must be Script, LocalScript or ModuleScript" end local source = tostring(args.source or "") local segments = splitPath(args.path) if #segments < 2 then return false, "path must include a service and a script name, e.g. ServerScriptService.GameManager" end local name = segments[#segments] local parent, created = ensureParent(segments) if not parent then return false, created end local existing = parent:FindFirstChild(name) local inst local action if existing and existing.ClassName == className then inst = existing setSource(inst, source) action = "Updated" else inst = Instance.new(className) inst.Name = name inst.Source = source if existing then for _, child in ipairs(existing:GetChildren()) do child.Parent = inst end existing:Destroy() action = "Replaced " .. existing.ClassName .. " with" else action = "Created" end inst.Parent = parent end local lines = { action .. " " .. pathOf(inst) .. " (" .. lineCount(source) .. " lines)." } if typeof(created) == "table" and #created > 0 then table.insert(lines, "Created folders: " .. table.concat(created, ", ")) end local warning = placementWarning(inst) if warning then table.insert(lines, warning) end local syntaxError = compileCheck(source, name) if syntaxError then table.insert(lines, "SYNTAX ERROR: " .. syntaxError) return false, table.concat(lines, "\n") end table.insert(lines, "Syntax OK.") return true, table.concat(lines, "\n") end local function plainCount(haystack, needle) local count, start = 0, 1 while true do local i, j = haystack:find(needle, start, true) if not i then return count end count += 1 start = j + 1 end end local function plainReplace(haystack, needle, replacement, all) local out, start = {}, 1 while true do local i, j = haystack:find(needle, start, true) if not i then break end table.insert(out, haystack:sub(start, i - 1)) table.insert(out, replacement) start = j + 1 if not all then break end end table.insert(out, haystack:sub(start)) return table.concat(out) end local function toolEditScript(args) local inst, err = findPath(args.path) if not inst then return false, err end if not inst:IsA("LuaSourceContainer") then return false, pathOf(inst) .. " is not a script" end local oldText = tostring(args.old_text or "") local newText = tostring(args.new_text or "") if oldText == "" then return false, "old_text is empty" end local source = readSource(inst) or "" local count = plainCount(source, oldText) if count == 0 then return false, "old_text not found in " .. pathOf(inst) .. ". Read the script again and copy the exact text (including indentation)." end if count > 1 and args.replace_all ~= true then return false, "old_text matches " .. count .. " places. Add more surrounding lines to make it unique, or set replace_all." end local updated = plainReplace(source, oldText, newText, args.replace_all == true) setSource(inst, updated) local syntaxError = compileCheck(updated, inst.Name) local message = "Edited " .. pathOf(inst) .. " (" .. count .. " replacement" .. (count > 1 and "s" or "") .. ")." if syntaxError then return false, message .. "\nSYNTAX ERROR: " .. syntaxError end return true, message .. " Syntax OK." end local function toolRunLuau(args) local code = tostring(args.code or "") if code == "" then return false, "code is empty" end local fn, compileError = compileChunk(code, "run_luau") if not fn then return false, "Compile error: " .. tostring(compileError) end local label = tostring(args.label or "Rateblox build") local recording local okRec, rec = pcall(function() return ChangeHistoryService:TryBeginRecording(label) end) if okRec then recording = rec end local started = os.clock() local ok, result, output = runCode(code, "run_luau") local elapsed = os.clock() - started if recording then pcall(function() ChangeHistoryService:FinishRecording( recording, ok and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel ) end) elseif ok then pcall(function() ChangeHistoryService:SetWaypoint(label) end) end local lines = {} if #output > 0 then table.insert(lines, "Output:\n" .. table.concat(output, "\n")) end if not ok then table.insert(lines, "Runtime error: " .. tostring(result)) if recording then table.insert(lines, "All changes from this call were rolled back — fix the error and run it again.") end return false, table.concat(lines, "\n") end if result ~= nil then table.insert(lines, "Returned: " .. fmtValue(result)) end table.insert(lines, string.format("OK in %.1fs.", elapsed)) return true, table.concat(lines, "\n") end local function toolDelete(args) local inst, err = findPath(args.path) if not inst then return false, err end if inst.Parent == game then return false, "Refusing to delete a service." end local name = pathOf(inst) local recording local okRec, rec = pcall(function() return ChangeHistoryService:TryBeginRecording("Rateblox delete") end) if okRec then recording = rec end inst:Destroy() if recording then pcall(function() ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit) end) end return true, "Deleted " .. name end local function toolInsertAsset(args) local assetId = tonumber(args.asset_id) if not assetId then return false, "asset_id is required" end local parent = workspace if args.parent and args.parent ~= "" then local found, err = findPath(args.parent) if not found then return false, err end parent = found end local recording local okRec, rec = pcall(function() return ChangeHistoryService:TryBeginRecording("Rateblox insert") end) if okRec then recording = rec end local root = insertAssetSafe(assetId, { name = args.name, size = tonumber(args.size), parent = parent, -- The agent sends `anchored` (default true). false leaves a crate or door free to move. anchor = args.anchored ~= false, }) if root then local position = asNumberList(args.position) local yaw = math.rad(tonumber(args.rotation_y) or 0) if position and #position >= 3 then local target = Vector3.new(position[1], position[2], position[3]) if root:IsA("Model") then root:PivotTo(CFrame.new(target) * CFrame.Angles(0, yaw, 0)) local cf, size = root:GetBoundingBox() local bottom = Vector3.new(cf.Position.X, cf.Position.Y - size.Y / 2, cf.Position.Z) root:PivotTo(root:GetPivot() + (target - bottom)) elseif root:IsA("BasePart") then root.CFrame = CFrame.new(target + Vector3.new(0, root.Size.Y / 2, 0)) * CFrame.Angles(0, yaw, 0) end end end if recording then pcall(function() ChangeHistoryService:FinishRecording( recording, root and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel ) end) end if not root then return false, "Could not load asset " .. assetId .. " (removed, private or not a model). Pick another id or build it from parts." end local info = "Inserted " .. pathOf(root) .. " [" .. root.ClassName .. "]" if root:IsA("Model") then local cf, size = root:GetBoundingBox() info ..= " bbox " .. fmtVector(size) .. " center " .. fmtVector(cf.Position) elseif root:IsA("BasePart") then info ..= " size " .. fmtVector(root.Size) .. " at " .. fmtVector(root.Position) end info ..= ". Scripts were stripped; " .. #root:GetDescendants() .. " descendants." return true, info end local Screenshot = (function() -- Rateblox AI · Screenshot -- The screenshot tool: optionally points the editor camera at an instance, -- captures the Studio viewport with CaptureService, reads it through an -- EditableImage and returns a small RGB copy so the agent can see its work. -- Read-only: the camera goes back to where it was. -- v9 capture: a fixed 1920x1080 frame through Studio's device emulator, -- retried when the frame comes out black; the user's device comes back after. local AssetService = game:GetService("AssetService") local CaptureService = game:GetService("CaptureService") local MAX_WIDTH = 768 local DEVICE_NAME = "Rateblox Capture 1080p" local DEVICE_WIDTH, DEVICE_HEIGHT = 1920, 1080 -- /api/plugin/results drops images over 1024 px a side, so frames are -- rendered at 1920x1080 and sent at 1024x576 unless asked otherwise. local SEND_WIDTH = 1024 -- Share of near-black pixels above which a frame counts as not rendered. local DARK_LIMIT = 0.97 -- Studio keeps a limited number of temporary captures per session: after -- roughly 190 of them every new capture fails to load (rbxtemp://191+, -- 2026-10-26). A retry only burns more of them, so these errors stop at once. local CAPTURE_LIMIT_HINT = "CAPTURE_UNAVAILABLE: Roblox Studio has stopped returning screenshots" .. " (it keeps a limited number of temporary captures per session). Restart Roblox Studio to restore them;" .. " do not take more screenshots in this session" local CAPTURE_HIDDEN_HINT = "CAPTURE_UNAVAILABLE: Roblox Studio did not render a screenshot." .. " The Studio window must stay open and visible (not minimized or covered) while the agent works" local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local LETTERS = {} for i = 1, 64 do LETTERS[i - 1] = ALPHABET:sub(i, i) end local function toBase64(buf) local length = buffer.len(buf) local parts = table.create(math.ceil(length / 3)) local count = 0 for i = 0, length - 1, 3 do local a = buffer.readu8(buf, i) local b = i + 1 < length and buffer.readu8(buf, i + 1) or 0 local c = i + 2 < length and buffer.readu8(buf, i + 2) or 0 local n = a * 65536 + b * 256 + c count += 1 parts[count] = LETTERS[bit32.rshift(n, 18)] .. LETTERS[bit32.band(bit32.rshift(n, 12), 63)] .. (i + 1 < length and LETTERS[bit32.band(bit32.rshift(n, 6), 63)] or "=") .. (i + 2 < length and LETTERS[bit32.band(n, 63)] or "=") end return table.concat(parts) end -- Center and size of what to look at: a part, a model, or every part under a -- folder/service (capped so huge places stay fast). local function boundsOf(inst) if inst:IsA("BasePart") then return inst.Position, inst.Size end if inst:IsA("Model") then local cf, size = inst:GetBoundingBox() return cf.Position, size end local low, high local seen = 0 for _, part in ipairs(inst:GetDescendants()) do if part:IsA("BasePart") and part.Transparency < 1 then local half = part.Size / 2 local a, b = part.Position - half, part.Position + half low = low and low:Min(a) or a high = high and high:Max(b) or b seen += 1 if seen >= 5000 then break end end end if not low then return nil, nil end return (low + high) / 2, high - low end local ANGLES = { iso = Vector3.new(1, 0.75, 1), front = Vector3.new(0, 0.3, 1), side = Vector3.new(1, 0.3, 0), top = Vector3.new(0.001, 1, 0.001), } local function capture() local done, contentId = false, nil local ok, err = pcall(function() CaptureService:CaptureScreenshot(function(id) contentId = id done = true end) end) if not ok then return nil, tostring(err) end local deadline = os.clock() + 8 while not done and os.clock() < deadline do task.wait(0.05) end if not done then return nil, "the capture did not finish" end return contentId end -- A captured frame as a small RGB copy: { width, height, rgb = base64 }. local function readCapture(contentId, maxWidth) local okImage, image = pcall(function() return AssetService:CreateEditableImageAsync(Content.fromUri(contentId)) end) if not okImage or not image then return nil, "Could not read the screenshot: " .. tostring(image) end local size = image.Size local okPixels, pixels = pcall(function() return image:ReadPixelsBuffer(Vector2.zero, size) end) image:Destroy() if not okPixels then return nil, "Could not read the screenshot pixels: " .. tostring(pixels) end local step = math.max(1, size.X / maxWidth, size.Y / maxWidth) local width, height = math.floor(size.X / step), math.floor(size.Y / step) local rgb = buffer.create(width * height * 3) local dark = 0 for y = 0, height - 1 do local row = math.floor(y * step) * size.X for x = 0, width - 1 do local from = (row + math.floor(x * step)) * 4 local to = (y * width + x) * 3 local r, g, b = buffer.readu8(pixels, from), buffer.readu8(pixels, from + 1), buffer.readu8(pixels, from + 2) buffer.writeu8(rgb, to, r) buffer.writeu8(rgb, to + 1, g) buffer.writeu8(rgb, to + 2, b) if r < 12 and g < 12 and b < 12 then dark += 1 end end if y % 64 == 0 then task.wait() end end -- Third value: share of near-black pixels (a frame that didn't render). return { width = width, height = height, rgb = toBase64(rgb) }, nil, dark / math.max(1, width * height) end -- Points the editor camera at args.instance or args.path (if given). -- Returns ok, target description or error, and a function that puts the -- camera back. local function aim(args) local camera = workspace.CurrentCamera local saved = camera.CFrame if typeof(args.instance) == "Instance" or (typeof(args.path) == "string" and args.path ~= "") then local inst, err = args.instance, nil if typeof(inst) ~= "Instance" then inst, err = findPath(args.path) end if not inst then return false, err end local center, size = boundsOf(inst) if not center then return false, args.path .. " has no visible parts to look at." end local radius = math.max(size.Magnitude / 2, 4) local distance = radius / math.tan(math.rad(camera.FieldOfView / 2)) * 1.1 local direction = (ANGLES[tostring(args.angle or "iso")] or ANGLES.iso).Unit camera.CFrame = CFrame.lookAt(center + direction * distance, center) task.wait(0.3) -- let the viewport render the new view return true, args.path, function() camera.CFrame = saved end end return true, "the current editor view", function() end end local function toolScreenshot(args) local aimed, target, restoreCamera = aim(args) if not aimed then return false, target end local contentId, captureError = capture() restoreCamera() if not contentId then return false, CAPTURE_HIDDEN_HINT .. " (" .. tostring(captureError) .. ")" end local shot, readError = readCapture(contentId, MAX_WIDTH) if not shot then if string.find(tostring(readError), "Failed to load texture", 1, true) then return false, CAPTURE_LIMIT_HINT end return false, readError end return true, { text = string.format("Screenshot of %s (%dx%d) — the image is attached below.", target, shot.width, shot.height), image = shot, } end -- 1080p device ----------------------------------------------------------------- local function deviceService() local ok, service = pcall(function() return game:GetService("StudioDeviceSimulatorService") end) return ok and service or nil end -- A landscape viewport at least this wide is captured as it is. local NATIVE_MIN_WIDTH = 1024 -- True when the user's own viewport already gives a good wide frame. local function viewportWideEnough() local camera = workspace.CurrentCamera local size = camera and camera.ViewportSize if not size or size.Y <= 0 then return false end local aspect = size.X / size.Y return size.X >= NATIVE_MIN_WIDTH and aspect >= 1.3 and aspect <= 2.4 end -- Switches the device emulator to a 1920x1080 desktop. Returns a restore -- function (safe to call more than once), an error when the emulator can't -- be used, and whether the emulator was skipped. It is skipped when the -- viewport is already wide: Studio has no API to close the emulator's -- toolbar, so turning it on left that toolbar open in the user's Studio -- after every capture (2026-10-16). local function forceDevice() if viewportWideEnough() then return function() end, nil, true end local service = deviceService() if not service then return function() end, "the device emulator is not available in this Studio" end local previous, previousScaling, device local ok, err = pcall(function() previous = service:GetDeviceAsync() pcall(function() previousScaling = service:GetScalingModeAsync() end) for _, id in ipairs(service:GetDeviceListAsync()) do local okInfo, info = pcall(function() return service:GetDeviceInfoAsync(id) end) if okInfo and typeof(info) == "table" and info.IsCustom and info.Name == DEVICE_NAME then device = id -- left over from a capture that never restored break end end device = device or service:CreateDeviceAsync({ Name = DEVICE_NAME, Width = DEVICE_WIDTH, Height = DEVICE_HEIGHT, PixelDensity = 96, DeviceForm = Enum.DeviceForm.Desktop, }) service:SetDeviceAsync(device) service:SetScalingModeAsync(Enum.DeviceSimulatorScalingMode.ActualResolution) end) local restored = false local function restore() if restored then return end restored = true pcall(function() if previous == nil or previous == "" or previous == "default" or previous == device then service:StopSimulationAsync() else service:SetDeviceAsync(previous) end end) if previousScaling ~= nil then pcall(function() service:SetScalingModeAsync(previousScaling) end) end if device then pcall(function() service:RemoveDeviceAsync(device) end) end end if not ok then restore() return function() end, tostring(err) end return restore, nil end -- Captures until a frame is not black. Only a black frame is retried: a read -- or capture error would fail again and use up Studio's capture budget. -- Returns shot, error, attempts used. local function captureClear(maxWidth, attempts) local lastError = "no frame" for attempt = 1, attempts do local contentId, captureError = capture() if not contentId then return nil, CAPTURE_HIDDEN_HINT .. " (" .. tostring(captureError) .. ")", attempt end local shot, readError, dark = readCapture(contentId, maxWidth) if shot and (dark or 0) < DARK_LIMIT then return shot, nil, attempt end if not shot then if string.find(tostring(readError), "Failed to load texture", 1, true) then return nil, CAPTURE_LIMIT_HINT, attempt end return nil, readError, attempt end lastError = "the frame came out black (the viewport had not rendered)" if attempt < attempts then task.wait(0.5 * attempt) end end return nil, lastError, attempts end -- capture tool (v9): { path?, angle?, max_width? (default 1024, up to 1920) }. local function toolCapture(args) local maxWidth = math.clamp(tonumber(args.max_width) or SEND_WIDTH, 256, DEVICE_WIDTH) local restoreDevice, deviceError, native = forceDevice() -- With the device emulator on, Studio renders its own editor view unless -- the camera is Scriptable: the frame showed sky instead of the target -- (2026-10-16). The user's camera type comes back afterwards. local camera = workspace.CurrentCamera local savedType = camera.CameraType local ok, success, result = pcall(function() if not deviceError and not native then task.wait(0.6) -- the viewport resizes to the device end camera.CameraType = Enum.CameraType.Scriptable local aimed, target, restoreCamera = aim(args) if not aimed then return false, target end local shot, captureError, attempts = captureClear(maxWidth, 2) restoreCamera() if not shot then return false, "Capture failed" .. (attempts > 1 and (" after " .. attempts .. " tries") or "") .. ": " .. tostring(captureError) .. ". The view was NOT checked." end return true, { text = string.format( "Capture of %s (%dx%d%s%s) — the image is attached below.", target, shot.width, shot.height, native and ", native viewport" or deviceError and (", native viewport: 1080p device unavailable — " .. deviceError) or ", 1920x1080 device", attempts > 1 and (", " .. attempts .. " tries") or "" ), image = shot, } end) pcall(function() camera.CameraType = savedType end) restoreDevice() if not ok then return false, "Capture failed: " .. tostring(success) end return success, result end return { toolScreenshot = toolScreenshot, toolCapture = toolCapture, capture = capture, captureClear = captureClear, SEND_WIDTH = SEND_WIDTH, forceDevice = forceDevice, readCapture = readCapture, } end)() local toolScreenshot = Screenshot.toolScreenshot local Playtest = (function() -- Rateblox AI · Playtest -- The playtest tool: a timed Play session that reports script errors, -- warnings and output, runs the agent's test scenario (server and/or client), -- checks the on-screen UI and can bring back a screenshot of the player's -- screen. local LogService = game:GetService("LogService") local RunService = game:GetService("RunService") local HttpService = game:GetService("HttpService") -- Playtest --------------------------------------------------------------- -- Adds a runner Script (ends the test after N seconds and hands back the -- server log) and a client forwarder (sends client output to the server and -- checks the UI), plus the agent's scenario scripts, then runs -- StudioTestService:ExecutePlayModeAsync. The screenshot is taken from the -- edit session while the game runs. local RUNNER_SOURCE = [==[ local StudioTestService = game:GetService("StudioTestService") local LogService = game:GetService("LogService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local startTime = os.time() local lines = {} local seen = {} local function add(kind, msg) local key = kind .. msg if seen[key] then seen[key] += 1 return end seen[key] = 1 if #lines < 600 then table.insert(lines, { kind = kind, msg = msg }) end end local remote = Instance.new("RemoteEvent") remote.Name = "__RatebloxTestLog" remote:SetAttribute("Start", startTime) remote:SetAttribute("AuditAt", __AUDIT_AT__) remote.Parent = ReplicatedStorage if __SHOT_AT__ > 0 then remote:SetAttribute("ShotAt", __SHOT_AT__) remote:SetAttribute("ShotWidth", __SHOT_WIDTH__) remote:SetAttribute("ShotToken", "__SHOT_TOKEN__") end -- The player's screen, sent in parts by the plugin inside the play client. local shot = nil remote.OnServerEvent:Connect(function(_, kind, a, b, c) if kind == "__shot_begin" then shot = { width = tonumber(a), height = tonumber(b), count = tonumber(c), parts = {}, got = 0 } elseif kind == "__shot_part" then if shot and typeof(b) == "string" and not shot.parts[a] then shot.parts[a] = b shot.got += 1 end elseif kind == "__shot_error" then add("client warning", "player screen capture failed: " .. tostring(a)) else add("client " .. tostring(kind), tostring(a)) end end) local function kindOf(messageType) if messageType == Enum.MessageType.MessageError then return "server error" end if messageType == Enum.MessageType.MessageWarning then return "server warning" end if messageType == Enum.MessageType.MessageOutput then return "server output" end return nil end for _, entry in ipairs(LogService:GetLogHistory()) do local kind = kindOf(entry.messageType) if kind and entry.timestamp >= startTime - 2 then add(kind, entry.message) end end LogService.MessageOut:Connect(function(msg, messageType) local kind = kindOf(messageType) if kind then add(kind, msg) end end) task.delay(__SECONDS__, function() local out = {} for _, line in ipairs(lines) do local count = seen[line.kind .. line.msg] or 1 table.insert(out, "[" .. line.kind .. "] " .. line.msg .. (count > 1 and (" (x" .. count .. ")") or "")) end -- the last parts of the frame may still be on their way local waited = 0 while shot and shot.count and shot.got < shot.count and waited < 2 do waited += task.wait(0.1) end local image = nil if shot and shot.count and shot.got == shot.count then image = { width = shot.width, height = shot.height, rgb = table.concat(shot.parts), source = "player" } end StudioTestService:EndTest({ log = table.concat(out, "\n"), image = image }) end) ]==] local CLIENT_SOURCE = [==[ local LogService = game:GetService("LogService") local Players = game:GetService("Players") local remote = game:GetService("ReplicatedStorage"):WaitForChild("__RatebloxTestLog", 15) if not remote then return end local startTime = remote:GetAttribute("Start") or 0 local function kindOf(messageType) if messageType == Enum.MessageType.MessageError then return "error" end if messageType == Enum.MessageType.MessageWarning then return "warning" end if messageType == Enum.MessageType.MessageOutput then return "output" end return nil end for _, entry in ipairs(LogService:GetLogHistory()) do local kind = kindOf(entry.messageType) if kind and entry.timestamp >= startTime - 2 then remote:FireServer(kind, entry.message) end end LogService.MessageOut:Connect(function(msg, messageType) local kind = kindOf(messageType) if kind then remote:FireServer(kind, msg) end end) -- UI check: what a player would see broken on screen. local function shown(obj) local node = obj while node and node:IsA("GuiObject") do if not node.Visible then return false end node = node.Parent end return true end local function audit() local gui = Players.LocalPlayer:FindFirstChild("PlayerGui") local camera = workspace.CurrentCamera if not gui or not camera then return end local view = camera.ViewportSize local count, offscreen, clipped, overlaps = 0, {}, {}, 0 local panels = {} for _, screen in ipairs(gui:GetChildren()) do if screen:IsA("ScreenGui") and screen.Enabled and screen.Name:sub(1, 2) ~= "__" then for _, obj in ipairs(screen:GetDescendants()) do if obj:IsA("GuiObject") and shown(obj) then count += 1 local p, s = obj.AbsolutePosition, obj.AbsoluteSize if s.X > 4 and s.Y > 4 and (p.X < -4 or p.Y < -64 or p.X + s.X > view.X + 4 or p.Y + s.Y > view.Y + 4) then table.insert(offscreen, obj:GetFullName():gsub("^Players%.[^%.]+%.PlayerGui%.", "") .. string.format(" (at %d,%d size %dx%d, screen %dx%d)", p.X, p.Y, s.X, s.Y, view.X, view.Y)) end if (obj:IsA("TextLabel") or obj:IsA("TextButton")) and obj.Text ~= "" and not obj.TextFits then table.insert(clipped, obj:GetFullName():gsub("^Players%.[^%.]+%.PlayerGui%.", "") .. ' "' .. obj.Text:sub(1, 30) .. '"') end if obj:IsA("Frame") and obj.BackgroundTransparency < 0.9 and s.X * s.Y > view.X * view.Y * 0.04 and obj.Parent:IsA("ScreenGui") then table.insert(panels, { obj = obj, p = p, s = s }) end end end end end for i = 1, #panels do for j = i + 1, #panels do local a, b = panels[i], panels[j] local w = math.min(a.p.X + a.s.X, b.p.X + b.s.X) - math.max(a.p.X, b.p.X) local h = math.min(a.p.Y + a.s.Y, b.p.Y + b.s.Y) - math.max(a.p.Y, b.p.Y) if w > 8 and h > 8 then overlaps += 1 end end end print(string.format("[UI] %d visible elements on a %dx%d screen; %d off-screen; %d with clipped text; %d overlapping top-level panels", count, view.X, view.Y, #offscreen, #clipped, overlaps)) for i = 1, math.min(6, #offscreen) do print("[UI] off-screen: " .. offscreen[i]) end for i = 1, math.min(6, #clipped) do print("[UI] text does not fit: " .. clipped[i]) end end local auditAt = remote:GetAttribute("AuditAt") if auditAt then task.delay(math.max(1, auditAt - (os.time() - startTime)), function() pcall(audit) end) end ]==] -- Helpers in scope for the agent's scenario code (server and client). local SHARED_HELPERS = [==[ local passed, failed = 0, 0 local function check(name, ok, detail) if ok then passed += 1 print("[TEST] PASS " .. tostring(name)) else failed += 1 warn("[TEST] FAIL " .. tostring(name) .. (detail ~= nil and (" — got " .. tostring(detail)) or "")) end return ok end local function find(path) local node = game for name in string.gmatch(path, "[^%.]+") do if node == game then local ok, service = pcall(game.GetService, game, name) node = (ok and service) or game:FindFirstChild(name) else node = node:FindFirstChild(name) end if not node then return nil end end return node end local function positionOf(target) if typeof(target) == "Vector3" then return target end if typeof(target) == "Instance" then if target:IsA("BasePart") then return target.Position end if target:IsA("Model") then return target:GetPivot().Position end if target:IsA("Attachment") then return target.WorldPosition end end error("expected a Vector3, part, model or attachment") end local function waitFor(fn, timeout) local waited = 0 while waited < (timeout or 5) do local ok, result = pcall(fn) if ok and result then return result end task.wait(0.1) waited += 0.1 end return nil end local function stat(name) local stats = player:FindFirstChild("leaderstats") local value = stats and stats:FindFirstChild(name) return value and value.Value end local function teleport(target) root.AssemblyLinearVelocity = Vector3.zero character:PivotTo(CFrame.new(positionOf(target) + Vector3.new(0, 4, 0))) task.wait(0.4) end local function walkTo(target, timeout) humanoid:MoveTo(positionOf(target)) local finished, reached = false, false local connection = humanoid.MoveToFinished:Connect(function(ok) reached = ok finished = true end) local waited = 0 while not finished and waited < (timeout or 8) do task.wait(0.1) waited += 0.1 end connection:Disconnect() return finished and reached end local function touch(part) root.AssemblyLinearVelocity = Vector3.zero character:PivotTo(CFrame.new(part.Position + Vector3.new(0, part.Size.Y / 2 + 2.5, 0))) task.wait(0.15) character:PivotTo(CFrame.new(part.Position)) task.wait(0.5) end ]==] local SERVER_HEADER = [==[ -- Rateblox test scenario (server). Removed after the playtest. local Players = game:GetService("Players") local player = Players:GetPlayers()[1] or Players.PlayerAdded:Wait() local character = player.Character or player.CharacterAdded:Wait() local humanoid = character:WaitForChild("Humanoid") local root = character:WaitForChild("HumanoidRootPart") task.wait(1.5) ]==] local CLIENT_HEADER = [==[ -- Rateblox test scenario (client). Removed after the playtest. local Players = game:GetService("Players") local player = Players.LocalPlayer local gui = player:WaitForChild("PlayerGui") local character = player.Character or player.CharacterAdded:Wait() local humanoid = character:WaitForChild("Humanoid") local root = character:WaitForChild("HumanoidRootPart") task.wait(2) ]==] local CLIENT_EXTRA = [==[ local function findGui(name) return gui:FindFirstChild(name, true) end local function prompt(target) local p = target if not p:IsA("ProximityPrompt") then p = target:FindFirstChildWhichIsA("ProximityPrompt", true) end if not p then error("no ProximityPrompt in " .. target:GetFullName()) end local where = p.Parent local position = where:IsA("Attachment") and where.WorldPosition or where:IsA("BasePart") and where.Position or where:GetPivot().Position root.AssemblyLinearVelocity = Vector3.zero character:PivotTo(CFrame.new(position + Vector3.new(0, 1, 3), position)) task.wait(0.4) p:InputHoldBegin() task.wait(math.max(0.05, p.HoldDuration) + 0.1) p:InputHoldEnd() task.wait(0.4) end -- Native input for ordinary ScreenGui buttons, not a direct callback shortcut. local function click(button) if not button or not button:IsA("GuiButton") then error("click expects a GuiButton") end local screen = button:FindFirstAncestorWhichIsA("ScreenGui") if not screen or not screen.Enabled then error("click requires an enabled ScreenGui") end local ancestor = button while ancestor and ancestor ~= screen do if ancestor:IsA("GuiObject") and not ancestor.Visible then error("button is hidden") end ancestor = ancestor.Parent end if button.AbsoluteSize.X <= 0 or button.AbsoluteSize.Y <= 0 then error("button has no visible size") end local okInput, input = pcall(function() return game:GetService("UserInputService"):CreateVirtualInput() end) if not okInput or not input then error("VirtualInput unavailable: real button interaction is unverified") end local center = button.AbsolutePosition + button.AbsoluteSize / 2 local inset = game:GetService("GuiService"):GetGuiInset() -- Screen space for the GUI's inset setting first, then the other one: -- which one VirtualInput expects differs between Studio builds. local positions = screen.IgnoreGuiInset and { center, center + inset } or { center + inset, center } local activated = false local connection = button.Activated:Connect(function() activated = true end) local down = false local position = positions[1] local ok, err = pcall(function() for _, candidate in positions do position = candidate input:SendMousePosition(position) down = true input:SendMouseButton(position, Enum.UserInputType.MouseButton1, true, 0) task.wait(0.08) input:SendMouseButton(position, Enum.UserInputType.MouseButton1, false, 0) down = false local elapsed = 0 while not activated and elapsed < 0.8 do task.wait(0.05) elapsed += 0.05 end if activated then break end end end) if down then pcall(function() input:SendMouseButton(position, Enum.UserInputType.MouseButton1, false, 0) end) end connection:Disconnect() if not ok then error("Native click failed: " .. tostring(err)) end if not activated then error("Native click did not activate the target; check occlusion, position and input settings") end task.wait(0.2) return true -- scenario must still assert the resulting behavior end local function press(button) local kit = game:GetService("ReplicatedStorage"):FindFirstChild("RatebloxUI") if kit then local UI = require(kit) if UI.press and UI.press(button) then task.wait(0.3) return true end end error("press() cannot drive this button. Test its real input through a supported API or report the click as unverified; existence alone does not prove interaction.") end ]==] local SCENARIO_FOOTER = [==[ if not ok then failed += 1 warn("[TEST] ERROR " .. tostring(err)) end print(string.format("[TEST] DONE %s: %d passed, %d failed", __SIDE__, passed, failed)) ]==] local function lineCount(text) local _, count = text:gsub("\n", "") return count end -- Lines before the agent's own code, so errors point at its line numbers. local SERVER_OFFSET = lineCount(SERVER_HEADER .. SHARED_HELPERS) + 1 local CLIENT_OFFSET = lineCount(CLIENT_HEADER .. SHARED_HELPERS .. CLIENT_EXTRA) + 1 local function scenarioSource(header, extra, body, side) return header .. SHARED_HELPERS .. extra .. "local ok, err = pcall(function()\n" .. body .. "\nend)\n" .. (SCENARIO_FOOTER:gsub("__SIDE__", '"' .. side .. '"')) end local function scenarioLines(line) line = line:gsub("[%w%.]*" .. TEST_PREFIX .. "Scenario:(%d+):", function(n) return "test:" .. (tonumber(n) - SERVER_OFFSET) .. ":" end) line = line:gsub("[%w%.]*" .. TEST_PREFIX .. "PlayerScenario:(%d+):", function(n) return "client_test:" .. (tonumber(n) - CLIENT_OFFSET) .. ":" end) return line end local function removeTestScripts() for _, serviceName in ipairs({ "ServerScriptService", "StarterPlayer", "ReplicatedStorage" }) do local service = game:GetService(serviceName) for _, inst in ipairs(service:GetDescendants()) do if inst.Name:sub(1, #TEST_PREFIX) == TEST_PREFIX then pcall(function() inst:Destroy() end) end end end end local function addTestScript(className, name, parent, source) local inst = Instance.new(className) inst.Name = name inst.Source = source inst.Parent = parent return inst end -- Our own plumbing; its log lines are hidden from the agent. local function isPlumbing(line) return line:find(TEST_PREFIX .. "Runner", 1, true) or line:find(TEST_PREFIX .. "Client:", 1, true) or line:find(TEST_PREFIX .. "Client'", 1, true) or line:find("__RatebloxTestLog", 1, true) end local function toolPlaytest(args) local okService, StudioTestService = pcall(function() return game:GetService("StudioTestService") end) if not okService or not StudioTestService then return false, "StudioTestService is not available in this Studio version — update Roblox Studio." end if RunService:IsRunning() then return false, "Studio is already in a play session. Ask the user to stop it (Stop button), then playtest again." end local serverTest = typeof(args.test) == "string" and args.test or (typeof(args.test_code) == "string" and args.test_code or "") local clientTest = typeof(args.client_test) == "string" and args.client_test or "" local hasScenario = serverTest:match("%S") ~= nil or clientTest:match("%S") ~= nil local seconds = math.clamp(tonumber(args.seconds) or (hasScenario and 20 or 10), 3, 60) local shotAt = 0 if args.screenshot == true or tonumber(args.screenshot_at) then shotAt = math.clamp(tonumber(args.screenshot_at) or (seconds - 3), 0.25, math.max(0.25, seconds - 2.5)) end local auditAt = math.max(2, seconds - 1.5) local shotToken = HttpService:GenerateGUID(false) removeTestScripts() local sss = game:GetService("ServerScriptService") local starterPlayerScripts = game:GetService("StarterPlayer"):FindFirstChildOfClass("StarterPlayerScripts") if not starterPlayerScripts then starterPlayerScripts = Instance.new("StarterPlayerScripts") starterPlayerScripts.Parent = game:GetService("StarterPlayer") end local runner = RUNNER_SOURCE :gsub("__SECONDS__", tostring(seconds)) :gsub("__AUDIT_AT__", tostring(auditAt)) :gsub("__SHOT_AT__", tostring(shotAt)) :gsub("__SHOT_WIDTH__", tostring(args.hd == true and 768 or 640)) :gsub("__SHOT_TOKEN__", shotToken) addTestScript("Script", TEST_PREFIX .. "Runner", sss, runner) addTestScript("LocalScript", TEST_PREFIX .. "Client", starterPlayerScripts, CLIENT_SOURCE) if serverTest:match("%S") then addTestScript("Script", TEST_PREFIX .. "Scenario", sss, scenarioSource(SERVER_HEADER, "", serverTest, "server")) end if clientTest:match("%S") then addTestScript("LocalScript", TEST_PREFIX .. "PlayerScenario", starterPlayerScripts, scenarioSource(CLIENT_HEADER, CLIENT_EXTRA, clientTest, "client")) end -- Fallback capture from the edit session, in case the runner never reports. local editLines = {} local connection = LogService.MessageOut:Connect(function(message, messageType) if #editLines < 200 and messageType ~= Enum.MessageType.MessageInfo then table.insert(editLines, "[" .. messageType.Name .. "] " .. message) end end) log("Playtest " .. seconds .. "s" .. (hasScenario and " with a test scenario" or "")) -- hd: frames at a fixed 1920x1080 (device emulator), retried when black. local hd = args.hd == true and shotAt > 0 local restoreDevice, deviceError = function() end, nil if hd then restoreDevice, deviceError = Screenshot.forceDevice() end local finished = false local okRun, result local editShot, editShotError, shotDone = nil, nil, false local playerShot = nil task.spawn(function() okRun, result = pcall(function() return StudioTestService:ExecutePlayModeAsync({}) end) finished = true end) -- The play client can't read its own capture (temporary texture), so the -- edit session captures the Studio window while the game is running. if shotAt > 0 then task.spawn(function() task.wait(shotAt + 1.5) if finished then editShotError = "the play session ended before the screenshot time" shotDone = true return end -- The player's screen: the play client captured it and left the id in -- the plugin's settings; the edit session can read that capture. -- `plugin` exists only in the plugin Script (not in the module build). local deadlineId = os.clock() + (plugin and 4 or 0) while os.clock() < deadlineId and not finished do local value = plugin:GetSetting("RatebloxPlayCapture") if typeof(value) == "string" and value:sub(1, #shotToken + 1) == shotToken .. "|" then plugin:SetSetting("RatebloxPlayCapture", nil) local shotFromPlayer, readError = Screenshot.readCapture(value:sub(#shotToken + 2), hd and Screenshot.SEND_WIDTH or 768) if shotFromPlayer then playerShot = shotFromPlayer shotDone = true return end editShotError = "reading the player's capture: " .. tostring(readError) break end task.wait(0.2) end if hd then editShot, editShotError = Screenshot.captureClear(Screenshot.SEND_WIDTH, 3) if editShotError then editShotError = "capture: " .. editShotError end else local contentId, captureError = Screenshot.capture() if not contentId then editShotError = "capture: " .. tostring(captureError) else editShot, editShotError = Screenshot.readCapture(contentId, 768) end end shotDone = true end) end local deadline = os.clock() + seconds + 45 while not finished and os.clock() < deadline do task.wait(0.25) end connection:Disconnect() removeTestScripts() -- the screenshot may still be encoding local shotDeadline = os.clock() + 10 while shotAt > 0 and not shotDone and os.clock() < shotDeadline do task.wait(0.1) end if shotAt > 0 and not shotDone then editShotError = "the screenshot did not finish in time" end restoreDevice() if hd and deviceError then editShotError = (editShotError and (editShotError .. "; ") or "") .. "1080p device unavailable: " .. deviceError end local lines = { "Playtest (" .. seconds .. "s)." } if not finished then table.insert(lines, "The play session did not end on its own (a script may be hanging). Ask the user to press Stop if Studio is still playing.") elseif not okRun then table.insert(lines, "Could not start the play session: " .. tostring(result)) end local report, image = "", nil if finished and okRun then if typeof(result) == "table" then report = typeof(result.log) == "string" and result.log or "" image = typeof(result.image) == "table" and result.image or nil elseif typeof(result) == "string" then report = result end end if report == "" and #editLines > 0 then report = table.concat(editLines, "\n") end image = image or playerShot local fromPlayer = image ~= nil if image then image.source = nil end image = image or editShot local tests, ui, errors, warnings, outputs = {}, {}, {}, {}, {} for _, raw in ipairs(string.split(report, "\n")) do local line = scenarioLines(raw) if line ~= "" and not isPlumbing(line) then if line:find("[TEST]", 1, true) then table.insert(tests, (line:gsub("^%[[%w ]+%] ", ""))) elseif line:find("[UI]", 1, true) then table.insert(ui, (line:gsub("^%[[%w ]+%] ", ""))) elseif line:find("error%]") or line:find("%[MessageError%]") then table.insert(errors, line) elseif line:find("warning%]") or line:find("%[MessageWarning%]") then table.insert(warnings, line) else table.insert(outputs, line) end end end if finished and okRun and report == "" then table.insert(lines, "The play session ended without a report (Studio may have stopped it early). Run playtest again, with fewer seconds.") end if hasScenario then local done = 0 for _, line in ipairs(tests) do if line:find("[TEST] DONE", 1, true) then done += 1 end end table.insert(lines, "Test scenario:" .. (#tests > 0 and ("\n" .. table.concat(tests, "\n")) or " no [TEST] lines — the scenario did not run (check errors below).")) local expected = (serverTest:match("%S") and 1 or 0) + (clientTest:match("%S") and 1 or 0) if done < expected then table.insert(lines, "The scenario did not reach its end before the session stopped: give playtest more seconds or shorten the waits.") end end table.insert(lines, "Errors (" .. #errors .. "):" .. (#errors > 0 and ("\n" .. table.concat(errors, "\n")) or " none")) table.insert(lines, "Warnings (" .. #warnings .. "):" .. (#warnings > 0 and ("\n" .. table.concat(warnings, "\n")) or " none")) if #ui > 0 then table.insert(lines, "UI check:\n" .. table.concat(ui, "\n")) end if #outputs > 0 then local tail = {} for i = math.max(1, #outputs - 60), #outputs do table.insert(tail, outputs[i]) end table.insert(lines, "Output (last " .. #tail .. "):\n" .. table.concat(tail, "\n")) else table.insert(lines, "Output: none") end if shotAt > 0 then table.insert(lines, fromPlayer and "Screenshot of the player's screen in Play is attached below." or image and "The player's screen could not be captured; attached is the editor view during Play (it does not show the player's camera or screen UI)." or ("No screenshot came back from the play session" .. (editShotError and (" (" .. tostring(editShotError) .. ")") or "") .. ".")) end local text = table.concat(lines, "\n") local ok = finished and okRun ~= false if image then return ok, { text = text, image = image } end return ok, text end return { RUNNER_SOURCE = RUNNER_SOURCE, CLIENT_SOURCE = CLIENT_SOURCE, removeTestScripts = removeTestScripts, addTestScript = addTestScript, toolPlaytest = toolPlaytest, } end)() local toolPlaytest = Playtest.toolPlaytest local removeTestScripts = Playtest.removeTestScripts local Codec = (function() -- Rateblox AI · Codec (v9) -- Converts between Studio values and the JSON of the v9 file mirror: -- canonical encoding (sorted keys, rounded numbers) so equal state always -- hashes the same, typed decoding with errors the agent can act on, file-safe -- instance names, a content hash and the catalog of properties a file shows. local Codec = {} -- Sentinel for "set this property to nil" (refs, CustomPhysicalProperties). local NONE = setmetatable({}, { __tostring = function() return "nil" end, }) Codec.NONE = NONE -- JSON text -------------------------------------------------------------------- local ESCAPES = { ['"'] = '\\"', ["\\"] = "\\\\", ["\n"] = "\\n", ["\r"] = "\\r", ["\t"] = "\\t", ["\b"] = "\\b", ["\f"] = "\\f", } local function jsonString(s) return '"' .. s:gsub('[%c"\\]', function(c) return ESCAPES[c] or string.format("\\u%04x", c:byte()) end) .. '"' end Codec.jsonString = jsonString -- Three decimals: enough for studs and degrees, and float noise from the -- engine (89.99999) never shows up as a change. local function jsonNumber(n) if n ~= n then return "0" elseif n == math.huge then return '"inf"' elseif n == -math.huge then return '"-inf"' end local r = math.round(n * 1000) / 1000 if r == 0 then return "0" end if r == math.floor(r) and math.abs(r) < 1e15 then return string.format("%d", r) end local text = string.format("%.3f", r):gsub("0+$", ""):gsub("%.$", "") return text end Codec.jsonNumber = jsonNumber local function isArray(t) local n = #t local count = 0 for _ in pairs(t) do count += 1 end return count == n end local function sortedKeys(t) local keys = {} for k in pairs(t) do table.insert(keys, tostring(k)) end table.sort(keys) return keys end -- One-line canonical JSON for plain Luau values (arrays, string-keyed maps). local function encodeJson(value) local kind = type(value) if kind == "number" then return jsonNumber(value) elseif kind == "string" then return jsonString(value) elseif kind == "boolean" then return tostring(value) elseif kind == "table" then if value == NONE then return "null" end local parts = {} if isArray(value) then for i, item in ipairs(value) do parts[i] = encodeJson(item) end return "[" .. table.concat(parts, ", ") .. "]" end for _, key in ipairs(sortedKeys(value)) do table.insert(parts, jsonString(key) .. ": " .. encodeJson(value[key])) end return "{" .. table.concat(parts, ", ") .. "}" end return "null" end Codec.encodeJson = encodeJson -- Multi-line object, one entry per line, so string edits of a file touch one -- property. `entries` is a sorted list of { key, fragment }. function Codec.block(entries, indent) if #entries == 0 then return "{}" end local inner = indent .. " " local lines = {} for i, entry in ipairs(entries) do lines[i] = inner .. jsonString(entry[1]) .. ": " .. entry[2] end return "{\n" .. table.concat(lines, ",\n") .. "\n" .. indent .. "}" end -- Content hash: FNV-1a 32 plus the length. Easy to reproduce on the server. function Codec.hash(text) local h = 2166136261 local length = #text for i = 1, length, 4096 do local bytes = { string.byte(text, i, math.min(i + 4095, length)) } for _, b in ipairs(bytes) do h = bit32.bxor(h, b) -- h * 16777619 mod 2^32 without losing precision: 16777619 = 2^24 + 403. h = (h * 403 + bit32.lshift(h, 24)) % 4294967296 end end return string.format("%08x:%d", h, length) end -- Names ------------------------------------------------------------------------ -- Instance names become path segments. Characters that would break a path, -- and endings that would read as a file kind, are percent-encoded; equal -- names among siblings get "~2", "~3" (a literal "~" is always encoded). local KIND_ENDINGS = { ".meta", ".server", ".client", ".json", ".luau", ".lua" } function Codec.encodeName(name) if name == "" then return "%" end local s = name:gsub('[%%/\\~:%*%?"<>|%c]', function(c) return string.format("%%%02X", c:byte()) end) if s == "." or s == ".." then return (s:gsub("%.", "%%2E")) end local lower = s:lower() if lower == "init" or lower:sub(1, 5) == "init." then s = "%" .. string.format("%02X", s:byte(1)) .. s:sub(2) end for _, ending in ipairs(KIND_ENDINGS) do if #lower > #ending and lower:sub(-#ending) == ending then s = s:sub(1, #s - #ending) .. "%2E" .. s:sub(#s - #ending + 2) break end end return s end -- Segment -> instance name: drops the "~N" sibling suffix and decodes. function Codec.decodeName(segment) segment = segment:gsub("~%d+$", "") if segment == "%" then return "" end return (segment:gsub("%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end)) end -- Mirror paths --------------------------------------------------------------- -- "Workspace/Map/House/init.json" -> logical "Workspace/Map/House", kind "json". -- Kinds: json (instance file), meta (script properties), script (with the -- class its suffix implies), nil (a bare path to an instance). local SCRIPT_ENDINGS = { { ".server.luau", "Script" }, { ".client.luau", "LocalScript" }, { ".server.lua", "Script" }, { ".client.lua", "LocalScript" }, { ".luau", "ModuleScript" }, { ".lua", "ModuleScript" }, } function Codec.parsePath(path) if type(path) ~= "string" then return nil end local p = path:gsub("\\", "/"):gsub("^%./", ""):gsub("^/+", ""):gsub("/+$", "") p = p:gsub("^src/", "") if p == "" then return nil end local function ending(suffix) return #p >= #suffix and p:sub(-#suffix) == suffix end local function base(suffix) local logical = p:sub(1, #p - #suffix) if logical:sub(-5) == "/init" then return logical:sub(1, -6), true end return logical, false end if ending(".meta.json") then local logical, init = base(".meta.json") return { logical = logical, kind = "meta", init = init } end if ending(".json") then local logical, init = base(".json") return { logical = logical, kind = "json", init = init } end for _, pair in ipairs(SCRIPT_ENDINGS) do if ending(pair[1]) then local logical, init = base(pair[1]) return { logical = logical, kind = "script", class = pair[2], init = init } end end return { logical = p, kind = nil } end function Codec.splitLogical(logical) local parent, last = logical:match("^(.*)/([^/]+)$") if not parent then return nil, logical end return parent, last end -- Encoding Studio values -------------------------------------------------------- local function hex(color) local function channel(v) return math.clamp(math.round(v * 255), 0, 255) end return string.format("#%02X%02X%02X", channel(color.R), channel(color.G), channel(color.B)) end local function vector(...) local parts = { ... } for i, n in ipairs(parts) do parts[i] = jsonNumber(n) end return "[" .. table.concat(parts, ", ") .. "]" end local function cframe(cf) local rx, ry, rz = cf:ToOrientation() local p = cf.Position return '{"orientation": ' .. vector(math.deg(rx), math.deg(ry), math.deg(rz)) .. ', "position": ' .. vector(p.X, p.Y, p.Z) .. "}" end Codec.cframe = cframe -- Value -> JSON fragment, or nil when the value is unset or unsupported. -- refPath(instance) -> mirror path of a referenced instance (or nil). function Codec.encodeValue(value, refPath) local kind = typeof(value) if kind == "nil" then return nil elseif kind == "boolean" then return tostring(value) elseif kind == "number" then return jsonNumber(value) elseif kind == "string" then return jsonString(value) elseif kind == "Vector3" then return vector(value.X, value.Y, value.Z) elseif kind == "Vector2" then return vector(value.X, value.Y) elseif kind == "Color3" then return jsonString(hex(value)) elseif kind == "BrickColor" or kind == "EnumItem" then return jsonString(value.Name) elseif kind == "UDim2" then return vector(value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset) elseif kind == "UDim" then return vector(value.Scale, value.Offset) elseif kind == "CFrame" then return cframe(value) elseif kind == "NumberRange" then return vector(value.Min, value.Max) elseif kind == "Rect" then return vector(value.Min.X, value.Min.Y, value.Max.X, value.Max.Y) elseif kind == "NumberSequence" then local points = value.Keypoints local constant = true for _, point in ipairs(points) do if point.Value ~= points[1].Value or point.Envelope ~= 0 then constant = false end end if constant then return jsonNumber(points[1].Value) end local parts = {} for i, point in ipairs(points) do parts[i] = point.Envelope ~= 0 and vector(point.Time, point.Value, point.Envelope) or vector(point.Time, point.Value) end return "[" .. table.concat(parts, ", ") .. "]" elseif kind == "ColorSequence" then local points = value.Keypoints local first = hex(points[1].Value) local constant = true for _, point in ipairs(points) do if hex(point.Value) ~= first then constant = false end end if constant then return jsonString(first) end local parts = {} for i, point in ipairs(points) do parts[i] = "[" .. jsonNumber(point.Time) .. ", " .. jsonString(hex(point.Value)) .. "]" end return "[" .. table.concat(parts, ", ") .. "]" elseif kind == "Font" then return '{"family": ' .. jsonString(value.Family) .. ', "style": ' .. jsonString(value.Style.Name) .. ', "weight": ' .. jsonString(value.Weight.Name) .. "}" elseif kind == "PhysicalProperties" then return '{"density": ' .. jsonNumber(value.Density) .. ', "elasticity": ' .. jsonNumber(value.Elasticity) .. ', "elasticityWeight": ' .. jsonNumber(value.ElasticityWeight) .. ', "friction": ' .. jsonNumber(value.Friction) .. ', "frictionWeight": ' .. jsonNumber(value.FrictionWeight) .. "}" elseif kind == "Content" then return jsonString(value.Uri or "") elseif kind == "Instance" then local path = refPath and refPath(value) return path and jsonString(path) or nil end return nil end -- Attributes keep their type: plain JSON for string/number/boolean, a tagged -- object for the rest, e.g. {"Vector3": [0, 5, 0]}. function Codec.encodeAttribute(value) local kind = typeof(value) if kind == "string" or kind == "number" or kind == "boolean" then return Codec.encodeValue(value) end local fragment = Codec.encodeValue(value) if not fragment then return nil end return "{" .. jsonString(kind) .. ": " .. fragment .. "}" end -- Decoding JSON into Studio values --------------------------------------------- local function show(json) local text = type(json) == "table" and encodeJson(json) or (type(json) == "string" and jsonString(json) or tostring(json)) if #text > 60 then text = text:sub(1, 57) .. "..." end return text end Codec.show = show local function toNumber(v) if type(v) == "number" then return v elseif v == "inf" then return math.huge elseif v == "-inf" then return -math.huge end return nil end -- Exactly n numbers from [a, b, ...] or {x=, y=, z=}. local AXES = { "x", "y", "z", "w" } local function numbers(json, n) if type(json) ~= "table" then return nil end local out = {} if #json > 0 then if #json ~= n then return nil end for i = 1, n do out[i] = toNumber(json[i]) if out[i] == nil then return nil end end return out end for i = 1, n do local v = json[AXES[i]] or json[AXES[i]:upper()] out[i] = toNumber(v) if out[i] == nil then return nil end end return out end local function levenshtein(a, b) a, b = a:lower(), b:lower() if a == b then return 0 end local previous = {} for j = 0, #b do previous[j] = j end for i = 1, #a do local current = { [0] = i } for j = 1, #b do local cost = a:sub(i, i) == b:sub(j, j) and 0 or 1 current[j] = math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost) end previous = current end return previous[#b] end -- Closest candidate name, or nil when nothing is close. function Codec.suggest(name, candidates) local best, bestDistance = nil, math.max(2, math.floor(#name / 3)) for _, candidate in ipairs(candidates) do local d = levenshtein(name, candidate) if d < bestDistance or (d == bestDistance and best == nil) then best, bestDistance = candidate, d end end return best end local function parseColor(json) if type(json) == "string" then local h = json:match("^#?(%x%x%x%x%x%x)$") if h then return Color3.fromRGB(tonumber(h:sub(1, 2), 16), tonumber(h:sub(3, 4), 16), tonumber(h:sub(5, 6), 16)) end return nil end local rgb = numbers(json, 3) if not rgb and type(json) == "table" then rgb = { toNumber(json.r or json.R), toNumber(json.g or json.G), toNumber(json.b or json.B) } if not (rgb[1] and rgb[2] and rgb[3]) then rgb = nil end end if rgb then for _, c in ipairs(rgb) do if c < 0 or c > 255 then return nil end end return Color3.fromRGB(rgb[1], rgb[2], rgb[3]) end return nil end local function enumItem(enumType, json) local items = enumType:GetEnumItems() if type(json) == "number" then for _, item in ipairs(items) do if item.Value == json then return item end end elseif type(json) == "string" then local name = json:match("([^%.]+)$") or json for _, item in ipairs(items) do if item.Name == name then return item end end for _, item in ipairs(items) do if item.Name:lower() == name:lower() then return item end end end local names = {} for _, item in ipairs(items) do table.insert(names, item.Name) end local hint = type(json) == "string" and Codec.suggest(json, names) local listed = #names <= 12 and (" Options: " .. table.concat(names, ", ") .. ".") or "" return nil, string.format( "unknown %s %s%s.%s", tostring(enumType), show(json), hint and (' — did you mean "' .. hint .. '"?') or "", hint and "" or listed ) end local function cframeFrom(json) if type(json) ~= "table" then return nil end local position = numbers(json, 3) or numbers(json.position or json.Position, 3) if not position then return nil end local orientation = { 0, 0, 0 } local given = json.orientation or json.Orientation if given ~= nil then orientation = numbers(given, 3) if not orientation then return nil end end return CFrame.new(position[1], position[2], position[3]) * CFrame.fromOrientation(math.rad(orientation[1]), math.rad(orientation[2]), math.rad(orientation[3])) end Codec.cframeFrom = cframeFrom local EXPECT = { number = "a number", string = "a string", boolean = "true or false", Vector3 = "[x, y, z] (3 numbers)", Vector2 = "[x, y] (2 numbers)", Color3 = '"#RRGGBB" or [r, g, b] (0-255)', BrickColor = 'a BrickColor name such as "Bright red"', UDim2 = "[xScale, xOffset, yScale, yOffset] (4 numbers)", UDim = "[scale, offset] (2 numbers)", CFrame = '{"position": [x, y, z], "orientation": [rx, ry, rz]} (degrees)', NumberRange = "a number or [min, max]", NumberSequence = "a number or [[time, value], ...] from time 0 to 1", ColorSequence = '"#RRGGBB" or [[time, "#RRGGBB"], ...] from time 0 to 1', Rect = "[minX, minY, maxX, maxY] (4 numbers)", Font = '{"family": "rbxasset://fonts/families/GothamSSm.json", "weight": "Bold", "style": "Normal"} or a font name', PhysicalProperties = '{"density", "friction", "elasticity", "frictionWeight", "elasticityWeight"} or null', Content = "an asset URL such as rbxassetid://123", Instance = "the mirror path of an object, or null", } -- JSON -> value of the same type as `sample` (the property's current value). -- `kind` is typeof(sample), or "Instance" for reference properties. -- ctx.resolve(path) -> Instance | nil, error. Returns true, value | false, error. function Codec.decodeValue(kind, json, sample, ctx) local function fail() return false, "expected " .. (EXPECT[kind] or kind) .. ", got " .. show(json) end if kind == "number" then local n = toNumber(json) if n == nil then return fail() end return true, n elseif kind == "string" then if type(json) == "number" then return true, tostring(json) end if type(json) ~= "string" then return fail() end return true, json elseif kind == "boolean" then if type(json) ~= "boolean" then return fail() end return true, json elseif kind == "Vector3" then local v = numbers(json, 3) if not v then return fail() end return true, Vector3.new(v[1], v[2], v[3]) elseif kind == "Vector2" then local v = numbers(json, 2) if not v then return fail() end return true, Vector2.new(v[1], v[2]) elseif kind == "Color3" then local color = parseColor(json) if not color then return fail() end return true, color elseif kind == "BrickColor" then if type(json) == "string" and not json:find("^#") then local ok, brick = pcall(BrickColor.new, json) if ok and brick.Name:lower() == json:lower() then return true, brick end return fail() end local color = parseColor(json) if not color then return fail() end return true, BrickColor.new(color) elseif kind == "EnumItem" then local item, err = enumItem(sample.EnumType, json) if not item then return false, err end return true, item elseif kind == "UDim2" then local v = numbers(json, 4) if not v and type(json) == "table" and #json == 2 then local x, y = numbers(json[1], 2), numbers(json[2], 2) v = x and y and { x[1], x[2], y[1], y[2] } end if not v then return fail() end return true, UDim2.new(v[1], v[2], v[3], v[4]) elseif kind == "UDim" then local v = numbers(json, 2) if not v then return fail() end return true, UDim.new(v[1], v[2]) elseif kind == "CFrame" then local cf = cframeFrom(json) if not cf then return fail() end return true, cf elseif kind == "NumberRange" then local n = toNumber(json) if n then return true, NumberRange.new(n) end local v = numbers(json, 2) if not v or v[1] > v[2] then return fail() end return true, NumberRange.new(v[1], v[2]) elseif kind == "NumberSequence" then local n = toNumber(json) if n then return true, NumberSequence.new(n) end if type(json) ~= "table" or #json < 2 then return fail() end local points = {} for i, entry in ipairs(json) do local v = numbers(entry, 2) or numbers(entry, 3) if not v then return fail() end points[i] = NumberSequenceKeypoint.new(v[1], v[2], v[3] or 0) end local ok, sequence = pcall(NumberSequence.new, points) if not ok then return false, "bad NumberSequence: " .. tostring(sequence) .. " (times must start at 0, rise, and end at 1)" end return true, sequence elseif kind == "ColorSequence" then local color = parseColor(json) if color then return true, ColorSequence.new(color) end if type(json) ~= "table" or #json < 2 then return fail() end local points = {} for i, entry in ipairs(json) do local t = type(entry) == "table" and toNumber(entry[1]) local c = type(entry) == "table" and parseColor(entry[2]) if not (t and c) then return fail() end points[i] = ColorSequenceKeypoint.new(t, c) end local ok, sequence = pcall(ColorSequence.new, points) if not ok then return false, "bad ColorSequence: " .. tostring(sequence) .. " (times must start at 0, rise, and end at 1)" end return true, sequence elseif kind == "Rect" then local v = numbers(json, 4) if not v then return fail() end return true, Rect.new(v[1], v[2], v[3], v[4]) elseif kind == "Font" then if type(json) == "string" then local ok, font = pcall(function() if json:find("://", 1, true) then return Font.new(json) end local enumFont = Enum.Font[json] return Font.fromEnum(enumFont) end) if ok and font then return true, font end return fail() end if type(json) ~= "table" or type(json.family) ~= "string" then return fail() end local weight, style = Enum.FontWeight.Regular, Enum.FontStyle.Normal if json.weight ~= nil then local item, err = enumItem(Enum.FontWeight, json.weight) if not item then return false, err end weight = item end if json.style ~= nil then local item, err = enumItem(Enum.FontStyle, json.style) if not item then return false, err end style = item end return true, Font.new(json.family, weight, style) elseif kind == "PhysicalProperties" or kind == "nil:PhysicalProperties" then if json == false or json == "default" or json == "null" then return true, NONE end if type(json) ~= "table" then return fail() end local d, f, e = toNumber(json.density), toNumber(json.friction), toNumber(json.elasticity) if not (d and f and e) then return fail() end return true, PhysicalProperties.new(d, f, e, toNumber(json.frictionWeight) or 1, toNumber(json.elasticityWeight) or 1) elseif kind == "Content" then if type(json) ~= "string" then return fail() end if json == "" then return true, Content.none end return true, Content.fromUri(json) elseif kind == "Instance" then if json == false or json == "" or json == "null" then return true, NONE end if type(json) ~= "string" then return fail() end local target, err = ctx.resolve(json) if not target then return false, err or ("no object at " .. show(json)) end return true, target end -- A type we can't express: accept plain JSON scalars as they are. if type(json) == "table" then return false, "this property's type (" .. kind .. ") can't be set from a file; use run_code" end return true, json end -- Attribute JSON -> value. `current` is the attribute's current value (its -- type wins for untagged arrays/strings). local ATTRIBUTE_TYPES = { Vector3 = true, Vector2 = true, Color3 = true, BrickColor = true, UDim2 = true, UDim = true, CFrame = true, NumberRange = true, NumberSequence = true, ColorSequence = true, Rect = true, Font = true, } function Codec.decodeAttribute(json, current) local kind = type(json) if kind == "string" or kind == "number" or kind == "boolean" then local currentKind = typeof(current) if kind == "string" and ATTRIBUTE_TYPES[currentKind] then return Codec.decodeValue(currentKind, json, current) end return true, json end if kind ~= "table" then return false, "attribute values are strings, numbers, booleans or tagged objects like {\"Vector3\": [0, 5, 0]}" end local keys = sortedKeys(json) if #keys == 1 and ATTRIBUTE_TYPES[keys[1]] then return Codec.decodeValue(keys[1], json[keys[1]]) end local currentKind = typeof(current) if ATTRIBUTE_TYPES[currentKind] then return Codec.decodeValue(currentKind, json, current) end return false, 'tag the attribute type, e.g. {"Vector3": [0, 5, 0]} or {"Color3": "#FF0000"}; got ' .. show(json) end -- Property catalog --------------------------------------------------------------- -- What a file shows for each class (non-default values only). Apply accepts -- any real property; the catalog only decides what is serialized. Order: -- entries match by IsA, so subclasses add to their base classes. local CATALOG = { { "BasePart", { "Anchored", "CanCollide", "CanTouch", "CanQuery", "CastShadow", "Color", "Material", "MaterialVariant", "Transparency", "Reflectance", "Size", "Position", "Orientation", "Massless", "CustomPhysicalProperties", "CollisionGroup" } }, { "Part", { "Shape" } }, { "MeshPart", { "MeshId", "TextureID" } }, { "TrussPart", { "Style" } }, { "SpawnLocation", { "Duration", "Neutral", "AllowTeamChangeOnTouch", "TeamColor", "Enabled" } }, { "Seat", { "Disabled" } }, { "VehicleSeat", { "Disabled", "MaxSpeed", "Torque", "TurnSpeed" } }, { "Model", { "Pivot", "PrimaryPart" } }, { "Decal", { "Texture", "Face", "Color3", "Transparency", "ZIndex" } }, { "Texture", { "StudsPerTileU", "StudsPerTileV", "OffsetStudsU", "OffsetStudsV" } }, { "SurfaceAppearance", { "ColorMap", "NormalMap", "RoughnessMap", "MetalnessMap", "Color" } }, { "SpecialMesh", { "MeshType", "MeshId", "TextureId", "Scale", "Offset", "VertexColor" } }, { "Attachment", { "Position", "Orientation", "Visible" } }, { "Light", { "Brightness", "Color", "Enabled", "Shadows" } }, { "PointLight", { "Range" } }, { "SpotLight", { "Range", "Angle", "Face" } }, { "SurfaceLight", { "Range", "Angle", "Face" } }, { "ParticleEmitter", { "Enabled", "Texture", "Rate", "Lifetime", "Speed", "SpreadAngle", "Color", "Size", "Transparency", "LightEmission", "LightInfluence", "Rotation", "RotSpeed", "Acceleration", "Drag", "EmissionDirection", "Orientation", "Squash", "ZOffset" } }, { "Beam", { "Attachment0", "Attachment1", "Enabled", "Color", "Transparency", "Width0", "Width1", "Texture", "TextureSpeed", "TextureLength", "FaceCamera", "Segments", "CurveSize0", "CurveSize1", "LightEmission", "LightInfluence" } }, { "Trail", { "Attachment0", "Attachment1", "Enabled", "Color", "Transparency", "Lifetime", "Texture", "WidthScale", "MinLength", "FaceCamera", "LightEmission" } }, { "Fire", { "Enabled", "Color", "SecondaryColor", "Heat", "Size" } }, { "Smoke", { "Enabled", "Color", "Opacity", "RiseVelocity", "Size" } }, { "Sparkles", { "Enabled", "SparkleColor" } }, { "Highlight", { "Adornee", "Enabled", "FillColor", "FillTransparency", "OutlineColor", "OutlineTransparency", "DepthMode" } }, { "Sound", { "SoundId", "Volume", "Looped", "Playing", "PlaybackSpeed", "RollOffMinDistance", "RollOffMaxDistance" } }, { "WeldConstraint", { "Part0", "Part1", "Enabled" } }, { "JointInstance", { "Part0", "Part1", "C0", "C1", "Enabled" } }, { "Constraint", { "Attachment0", "Attachment1", "Enabled", "Visible" } }, { "HingeConstraint", { "ActuatorType", "AngularVelocity", "AngularSpeed", "MotorMaxTorque", "ServoMaxTorque", "TargetAngle", "LimitsEnabled", "LowerAngle", "UpperAngle" } }, { "PrismaticConstraint", { "ActuatorType", "Speed", "Velocity", "MotorMaxForce", "ServoMaxForce", "TargetPosition", "LimitsEnabled", "LowerLimit", "UpperLimit" } }, { "RopeConstraint", { "Length" } }, { "SpringConstraint", { "Stiffness", "Damping", "FreeLength" } }, { "ProximityPrompt", { "ActionText", "ObjectText", "HoldDuration", "MaxActivationDistance", "KeyboardKeyCode", "RequiresLineOfSight", "Enabled", "Style" } }, { "ClickDetector", { "MaxActivationDistance" } }, { "ValueBase", { "Value" } }, { "Humanoid", { "DisplayName", "Health", "MaxHealth", "WalkSpeed", "JumpPower", "DisplayDistanceType" } }, { "Tool", { "RequiresHandle", "CanBeDropped", "ToolTip", "TextureId" } }, { "Team", { "TeamColor", "AutoAssignable" } }, { "BaseScript", { "Enabled" } }, { "Script", { "RunContext" } }, -- GUI { "LayerCollector", { "Enabled", "ResetOnSpawn", "ZIndexBehavior" } }, { "ScreenGui", { "IgnoreGuiInset", "DisplayOrder", "ScreenInsets" } }, { "BillboardGui", { "Adornee", "Size", "StudsOffset", "ExtentsOffset", "AlwaysOnTop", "MaxDistance", "LightInfluence" } }, { "SurfaceGui", { "Adornee", "Face", "SizingMode", "PixelsPerStud", "CanvasSize", "AlwaysOnTop", "LightInfluence" } }, { "GuiObject", { "Position", "Size", "AnchorPoint", "AutomaticSize", "BackgroundColor3", "BackgroundTransparency", "BorderSizePixel", "BorderColor3", "Visible", "ZIndex", "LayoutOrder", "Rotation", "ClipsDescendants" } }, { "GuiButton", { "AutoButtonColor" } }, { "TextLabel", { "Text", "TextColor3", "TextSize", "FontFace", "TextScaled", "TextWrapped", "TextXAlignment", "TextYAlignment", "TextTransparency", "TextStrokeColor3", "TextStrokeTransparency", "RichText", "LineHeight" } }, { "TextButton", { "Text", "TextColor3", "TextSize", "FontFace", "TextScaled", "TextWrapped", "TextXAlignment", "TextYAlignment", "TextTransparency", "TextStrokeColor3", "TextStrokeTransparency", "RichText" } }, { "TextBox", { "Text", "TextColor3", "TextSize", "FontFace", "TextScaled", "TextWrapped", "TextXAlignment", "TextYAlignment", "TextTransparency", "PlaceholderText", "PlaceholderColor3", "ClearTextOnFocus", "MultiLine", "TextEditable" } }, { "ImageLabel", { "Image", "ImageColor3", "ImageTransparency", "ScaleType", "SliceCenter", "SliceScale", "TileSize", "ImageRectOffset", "ImageRectSize" } }, { "ImageButton", { "Image", "ImageColor3", "ImageTransparency", "ScaleType", "SliceCenter", "SliceScale", "HoverImage", "PressedImage", "ImageRectOffset", "ImageRectSize" } }, { "ScrollingFrame", { "CanvasSize", "AutomaticCanvasSize", "ScrollingDirection", "ScrollBarThickness", "ScrollBarImageColor3", "ScrollingEnabled" } }, { "ViewportFrame", { "Ambient", "LightColor", "LightDirection" } }, { "UICorner", { "CornerRadius" } }, { "UIStroke", { "Color", "Thickness", "Transparency", "ApplyStrokeMode", "LineJoinMode", "Enabled" } }, { "UIGradient", { "Color", "Transparency", "Rotation", "Offset", "Enabled" } }, { "UIListLayout", { "FillDirection", "Padding", "SortOrder", "HorizontalAlignment", "VerticalAlignment", "Wraps" } }, { "UIGridLayout", { "CellSize", "CellPadding", "FillDirection", "SortOrder", "HorizontalAlignment", "VerticalAlignment", "FillDirectionMaxCells" } }, { "UIPadding", { "PaddingTop", "PaddingBottom", "PaddingLeft", "PaddingRight" } }, { "UIAspectRatioConstraint", { "AspectRatio", "AspectType", "DominantAxis" } }, { "UIScale", { "Scale" } }, { "UISizeConstraint", { "MinSize", "MaxSize" } }, { "UITextSizeConstraint", { "MinTextSize", "MaxTextSize" } }, -- World { "Workspace", { "Gravity" } }, { "Lighting", { "Ambient", "OutdoorAmbient", "Brightness", "ClockTime", "GeographicLatitude", "ColorShift_Top", "ColorShift_Bottom", "EnvironmentDiffuseScale", "EnvironmentSpecularScale", "ExposureCompensation", "FogColor", "FogStart", "FogEnd", "GlobalShadows", "ShadowSoftness" } }, { "Atmosphere", { "Density", "Offset", "Color", "Decay", "Glare", "Haze" } }, { "Sky", { "SkyboxBk", "SkyboxDn", "SkyboxFt", "SkyboxLf", "SkyboxRt", "SkyboxUp", "SunAngularSize", "MoonAngularSize", "StarCount", "CelestialBodiesShown" } }, { "Clouds", { "Cover", "Density", "Color", "Enabled" } }, { "BloomEffect", { "Enabled", "Intensity", "Size", "Threshold" } }, { "ColorCorrectionEffect", { "Enabled", "Brightness", "Contrast", "Saturation", "TintColor" } }, { "BlurEffect", { "Enabled", "Size" } }, { "SunRaysEffect", { "Enabled", "Intensity", "Spread" } }, { "DepthOfFieldEffect", { "Enabled", "FarIntensity", "FocusDistance", "InFocusRadius", "NearIntensity" } }, { "StarterPlayer", { "CharacterWalkSpeed", "CharacterJumpPower", "CameraMaxZoomDistance", "CameraMinZoomDistance", "CameraMode" } }, } Codec.CATALOG = CATALOG -- Properties whose value is another instance (nil when unset). Codec.REF_PROPS = { PrimaryPart = true, Part0 = true, Part1 = true, Adornee = true, Attachment0 = true, Attachment1 = true, } -- Pseudo-property: a Model's pivot, applied with PivotTo so the whole model moves. Codec.PSEUDO = { Pivot = "Model" } -- Always shown, and left alone when a file omits them (moving a part to the -- origin because a line went missing would be a silent disaster). Codec.ALWAYS = { Position = { "BasePart", "GuiObject" }, Size = { "BasePart", "GuiObject" } } local catalogCache = {} function Codec.propsFor(inst) local class = inst.ClassName local cached = catalogCache[class] if cached then return cached end local seen, list = {}, {} -- Workspace is a Model, but moving the whole world by its pivot is never meant. if class == "Workspace" then seen.Pivot, seen.PrimaryPart = true, true end for _, entry in ipairs(CATALOG) do if inst:IsA(entry[1]) then for _, prop in ipairs(entry[2]) do if not seen[prop] then seen[prop] = true table.insert(list, prop) end end end end table.sort(list) catalogCache[class] = list return list end function Codec.isAlways(inst, prop) local classes = Codec.ALWAYS[prop] if not classes then return false end for _, class in ipairs(classes) do if inst:IsA(class) then return true end end return false end function Codec.isRef(inst, prop) return Codec.REF_PROPS[prop] == true or (prop == "Value" and inst:IsA("ObjectValue")) end return Codec end)() local Mirror = (function() -- Rateblox AI · Mirror (v9) -- The place as Rojo-like files for the server's mirror: -- Script / LocalScript / ModuleScript -> Name.server.luau / .client.luau / .luau -- (+ Name.meta.json for non-default script properties, attributes, tags) -- Model / Folder / anything with children -> Name/ + init.json (or init.*.luau) -- other instances -> Name.json { class, props (non-default), attributes, tags, bounds } -- Ids are session-stable and live only in plugin memory. sync_manifest uploads -- hashes (whole unchanged folders collapse to one hash), sync_read sends the -- bodies the server asks for, every changing command returns a delta, and -- apply_diff turns file writes into create/update/delete/move with typed, -- validated properties. A failed op rolls the whole diff back. local NONE = Codec.NONE local jsonString = Codec.jsonString local ROOTS = { "Workspace", "Lighting", "ReplicatedFirst", "ReplicatedStorage", "ServerScriptService", "ServerStorage", "StarterGui", "StarterPack", "StarterPlayer", "SoundService", "Teams", } local SKIP_CLASSES = { Camera = true, Terrain = true } local CONTAINERS = { Model = true, Folder = true, Configuration = true, Actor = true, WorldModel = true } local SCRIPT_SUFFIX = { Script = ".server.luau", LocalScript = ".client.luau", ModuleScript = ".luau" } local TEST_PREFIX = "__RatebloxTest" local MAX_NODES = 60000 local YIELD_EVERY = 2000 local DELTA_BODY_BUDGET = 200000 local READ_BUDGET = 400000 -- Applied first so a size or mesh lands on the right shape. local FIRST_PROPS = { Shape = 1, MeshId = 2, TextureID = 3, MeshType = 4 } local Mirror = {} Mirror.__index = Mirror local function defaultHooks() local ChangeHistoryService = game:GetService("ChangeHistoryService") return { yield = function() task.wait() end, setSource = function(inst, source) inst.Source = source end, compileCheck = function() return nil end, placementWarning = function() return nil end, jsonDecode = function(text) return game:GetService("HttpService"):JSONDecode(text) end, beginRecording = function(label) local ok, recording = pcall(function() return ChangeHistoryService:TryBeginRecording(label) end) return ok and recording or nil end, finishRecording = function(recording, commit) if recording then pcall(function() ChangeHistoryService:FinishRecording( recording, commit and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel ) end) elseif commit then pcall(function() ChangeHistoryService:SetWaypoint("Rateblox apply") end) end end, } end function Mirror.new(hooks) local self = setmetatable({}, Mirror) local base = hooks and hooks.noDefaults and {} or defaultHooks() for key, fn in pairs(hooks or {}) do base[key] = fn end self.hooks = base self.ids = setmetatable({}, { __mode = "k" }) self.nextId = 0 self.session = base.session or string.format("%x%x", os.time(), math.random(0, 0xFFFFFF)) self.defaultCache = {} self.propsCache = {} self.extraProps = {} -- class -> { prop = true } learned from successful writes self.cache = nil -- last snapshot: files by path, dir hashes self.active = false -- true once the server asked for a manifest this session return self end function Mirror:idOf(inst) local id = self.ids[inst] if not id then self.nextId += 1 id = tostring(self.nextId) self.ids[inst] = id end return id end -- Properties --------------------------------------------------------------------- local function readProp(inst, prop) if prop == "Pivot" then return inst:GetPivot() end return inst[prop] end local function hasParts(model) for _, d in ipairs(model:GetDescendants()) do if d:IsA("BasePart") then return true end end return false end local function writeProp(inst, prop, value) if value == NONE then value = nil end if prop == "Pivot" then if hasParts(inst) then inst:PivotTo(value) else inst.WorldPivot = value end return end inst[prop] = value end -- Does `prop` exist on `inst`? Returns exists, currentValue, decodeKind. local function probe(inst, prop) if prop == "Pivot" then if inst:IsA("Model") then return true, inst:GetPivot(), "CFrame" end return false end local ok, value = pcall(function() return inst[prop] end) if not ok then return false end local kind = typeof(value) if kind == "function" or kind == "RBXScriptSignal" then return false end if kind == "Instance" and not Codec.isRef(inst, prop) and value.Parent == inst and value.Name == prop then return false -- a child that happens to share the name end if Codec.isRef(inst, prop) then return true, value, "Instance" end if kind == "nil" and prop == "CustomPhysicalProperties" then return true, value, "nil:PhysicalProperties" end return true, value, kind end function Mirror:propsOf(inst) local class = inst.ClassName local cached = self.propsCache[class] if cached then return cached end local list = table.clone(Codec.propsFor(inst)) local seen = {} for _, prop in ipairs(list) do seen[prop] = true end for prop in pairs(self.extraProps[class] or {}) do if not seen[prop] then table.insert(list, prop) end end table.sort(list) self.propsCache[class] = list return list end -- Default values of a class, from a fresh instance: { raw = {}, frag = {} }, -- or false for classes that can't be created (services). function Mirror:defaults(inst) local class = inst.ClassName local cached = self.defaultCache[class] if cached ~= nil then return cached end local ok, fresh = pcall(Instance.new, class) if not ok or not fresh then self.defaultCache[class] = false return false end local result = { raw = {}, frag = {} } for _, prop in ipairs(self:propsOf(inst)) do local okRead, value = pcall(readProp, fresh, prop) if okRead then result.raw[prop] = value == nil and NONE or value result.frag[prop] = Codec.encodeValue(value, function() return nil end) end end fresh:Destroy() self.defaultCache[class] = result return result end function Mirror:learnProp(inst, prop) local class = inst.ClassName for _, known in ipairs(self:propsOf(inst)) do if known == prop then return end end self.extraProps[class] = self.extraProps[class] or {} self.extraProps[class][prop] = true self.propsCache[class] = nil self.defaultCache[class] = nil end -- Walk --------------------------------------------------------------------------- local function walkable(inst) if SKIP_CLASSES[inst.ClassName] then return false end if inst.Name:sub(1, #TEST_PREFIX) == TEST_PREFIX then return false end local ok, archivable = pcall(function() return inst.Archivable end) return not ok or archivable ~= false end -- Structure only: nodes in depth-first order, index by logical path and by instance. function Mirror:walk() local nodes, index, byInst = {}, {}, {} local count, truncated = 0, false local hooks = self.hooks local function visit(inst, logical, parent) if count >= MAX_NODES then truncated = true return nil end count += 1 if count % YIELD_EVERY == 0 and hooks.yield then hooks.yield() end local node = { inst = inst, id = self:idOf(inst), class = inst.ClassName, logical = logical, parent = parent, children = {}, files = {}, } table.insert(nodes, node) index[logical] = node byInst[inst] = node local used = {} for _, child in ipairs(inst:GetChildren()) do if walkable(child) then local segment = Codec.encodeName(child.Name) local n = (used[segment] or 0) + 1 used[segment] = n if n > 1 then segment ..= "~" .. n end local childNode = visit(child, logical .. "/" .. segment, node) if childNode then table.insert(node.children, childNode) end end end node.dir = #node.children > 0 or CONTAINERS[node.class] == true or parent == nil return node end for _, name in ipairs(ROOTS) do local service = game:FindService(name) if service then visit(service, name, nil) end end return { nodes = nodes, index = index, byInst = byInst, truncated = truncated } end local function filePaths(node) local suffix = SCRIPT_SUFFIX[node.class] if suffix then if node.dir then return node.logical .. "/init" .. suffix, node.logical .. "/init.meta.json" end return node.logical .. suffix, node.logical .. ".meta.json" end return node.dir and (node.logical .. "/init.json") or (node.logical .. ".json") end Mirror.filePaths = filePaths -- World-axis box of a part: { minX, minY, minZ, maxX, maxY, maxZ }. local function partBox(part) local cf, size = part.CFrame, part.Size local hx, hy, hz = size.X / 2, size.Y / 2, size.Z / 2 local r, u, b = cf.XVector, cf.YVector, cf.ZVector local ex = math.abs(r.X) * hx + math.abs(u.X) * hy + math.abs(b.X) * hz local ey = math.abs(r.Y) * hx + math.abs(u.Y) * hy + math.abs(b.Y) * hz local ez = math.abs(r.Z) * hx + math.abs(u.Z) * hy + math.abs(b.Z) * hz local p = cf.Position return { p.X - ex, p.Y - ey, p.Z - ez, p.X + ex, p.Y + ey, p.Z + ez } end local function union(a, b) if not a then return b and table.clone(b) end if b then for i = 1, 3 do a[i] = math.min(a[i], b[i]) a[i + 3] = math.max(a[i + 3], b[i + 3]) end end return a end local function boxFragment(box) return '{"center": ' .. Codec.encodeJson({ (box[1] + box[4]) / 2, (box[2] + box[5]) / 2, (box[3] + box[6]) / 2 }) .. ', "size": ' .. Codec.encodeJson({ box[4] - box[1], box[5] - box[2], box[6] - box[3] }) .. "}" end -- Computed, read-only "bounds" of a part, a model or a GUI object. local function boundsOf(node) local inst = node.inst if node.parent == nil then return nil -- a service: its bounds would change with every edit end if (inst:IsA("BasePart") or inst:IsA("Model")) and node.box then return boxFragment(node.box) end if inst:IsA("GuiObject") then -- Text on signs (SurfaceGui/BillboardGui) gets surface pixels that change -- whenever it renders: no bounds, or every delta would list it. local okWorld, world = pcall(function() return inst:FindFirstAncestorWhichIsA("SurfaceGui") or inst:FindFirstAncestorWhichIsA("BillboardGui") end) if okWorld and world then return nil end local ok, fragment = pcall(function() local p, s = inst.AbsolutePosition, inst.AbsoluteSize return '{"position": ' .. Codec.encodeJson({ p.X, p.Y }) .. ', "size": ' .. Codec.encodeJson({ s.X, s.Y }) .. "}" end) return ok and fragment or nil end return nil end -- Serialize ------------------------------------------------------------------------ -- Returns main body, meta body (scripts only; nil when nothing to say). function Mirror:serialize(node, refPath) local inst = node.inst local defaults = self:defaults(inst) local props = {} for _, prop in ipairs(self:propsOf(inst)) do local ok, value = pcall(readProp, inst, prop) if ok then local fragment = Codec.encodeValue(value, refPath) if fragment ~= nil and (not defaults or Codec.isAlways(inst, prop) or fragment ~= defaults.frag[prop]) then table.insert(props, { prop, fragment }) end end end local attributes = {} local okAttributes, map = pcall(function() return inst:GetAttributes() end) if okAttributes then for name, value in pairs(map) do local fragment = Codec.encodeAttribute(value) if fragment then table.insert(attributes, { name, fragment }) end end table.sort(attributes, function(a, b) return a[1] < b[1] end) end local okTags, tags = pcall(function() return game:GetService("CollectionService"):GetTags(inst) end) tags = okTags and tags or {} table.sort(tags) local entries = {} if SCRIPT_SUFFIX[node.class] then local okSource, source = pcall(function() return inst.Source end) if #props > 0 then table.insert(entries, { "props", Codec.block(props, " ") }) end if #attributes > 0 then table.insert(entries, { "attributes", Codec.block(attributes, " ") }) end if #tags > 0 then table.insert(entries, { "tags", Codec.encodeJson(tags) }) end return okSource and source or "", #entries > 0 and (Codec.block(entries, "") .. "\n") or nil end table.insert(entries, { "class", jsonString(node.class) }) table.insert(entries, { "props", Codec.block(props, " ") }) if #attributes > 0 then table.insert(entries, { "attributes", Codec.block(attributes, " ") }) end if #tags > 0 then table.insert(entries, { "tags", Codec.encodeJson(tags) }) end local bounds = boundsOf(node) if bounds then table.insert(entries, { "bounds", bounds }) end return Codec.block(entries, "") .. "\n", nil end -- Full walk with bodies and hashes; folder hashes cover everything below them. function Mirror:snapshot() local tree = self:walk() local nodes = tree.nodes -- Children come after their parent in depth-first order, so the reverse -- order computes boxes bottom-up. for i = #nodes, 1, -1 do local node = nodes[i] local box if node.inst:IsA("BasePart") then local ok, own = pcall(partBox, node.inst) box = ok and own or nil end for _, child in ipairs(node.children) do box = union(box, child.box) end node.box = box end local function refPath(target) local node = tree.byInst[target] return node and node.logical end local previous = self.cache and self.cache.files or {} local files, list = {}, {} local function add(node, path, body) local old = previous[path] local entry = { path = path, id = node.id, class = node.class, body = body, hash = (old and old.body == body) and old.hash or Codec.hash(body), } files[path] = entry table.insert(list, entry) table.insert(node.files, entry) end for i, node in ipairs(nodes) do if i % YIELD_EVERY == 0 and self.hooks.yield then self.hooks.yield() end local main, meta = self:serialize(node, refPath) local mainPath, metaPath = filePaths(node) add(node, mainPath, main) if meta then add(node, metaPath, meta) end end local dirs = {} for i = #nodes, 1, -1 do local node = nodes[i] if node.dir then local lines = {} for _, entry in ipairs(node.files) do table.insert(lines, entry.path .. "=" .. entry.hash) end for _, child in ipairs(node.children) do if child.dir then table.insert(lines, child.logical .. "/=" .. child.dirHash) else for _, entry in ipairs(child.files) do table.insert(lines, entry.path .. "=" .. entry.hash) end end end table.sort(lines) node.dirHash = Codec.hash(table.concat(lines, "\n")) dirs[node.logical .. "/"] = node.dirHash end end return { tree = tree, files = files, list = list, dirs = dirs, truncated = tree.truncated } end -- Sync commands ------------------------------------------------------------------ local function fileEntry(entry, withBody) local out = { p = entry.path, id = entry.id, c = entry.class, h = entry.hash } if withBody then out.body = entry.body end return out end -- args.known: { [path] = hash } of what the server already has (folders end -- in "/"). Returns only what differs, folders that match as a whole, and -- known paths that no longer exist. function Mirror:manifest(args) args = args or {} local snap = self:snapshot() self.cache = snap self.active = true local known = type(args.known) == "table" and args.known or nil local withBodies = args.bodies == true local budget = tonumber(args.max_bytes) or READ_BUDGET local out, dirs, same, seen = {}, {}, {}, {} local sameSet = {} local function emit(node) local dirPath = node.logical .. "/" if node.dir and known and known[dirPath] == node.dirHash then table.insert(same, dirPath) sameSet[dirPath] = true return end for _, entry in ipairs(node.files) do seen[entry.path] = true if not known or known[entry.path] ~= entry.hash then local body = withBodies and budget >= #entry.body if body then budget -= #entry.body end table.insert(out, fileEntry(entry, body)) end end if node.dir then seen[dirPath] = true table.insert(dirs, { p = dirPath, h = node.dirHash }) for _, child in ipairs(node.children) do emit(child) end end end for _, node in ipairs(snap.tree.nodes) do if node.parent == nil then emit(node) end end local removed = {} if known then for path in pairs(known) do if not seen[path] then local covered = false local prefix = "" for segment in path:gmatch("[^/]+/") do prefix ..= segment if sameSet[prefix] and prefix ~= path then covered = true break end end if not covered and not sameSet[path] then table.insert(removed, path) end end end table.sort(removed) end local data = { session = self.session, files = out, dirs = dirs, same = same, removed = removed, total = #snap.list, truncated = snap.truncated or nil, } local text = string.format( "Mirror: %d files; %d sent, %d folders unchanged, %d removed.%s", #snap.list, #out, #same, #removed, snap.truncated and (" Place has more than " .. MAX_NODES .. " objects; the rest is not mirrored.") or "" ) return true, text, data end -- args.paths: file paths to send bodies for (from the last manifest/delta). function Mirror:read(args) args = args or {} if not self.cache or args.refresh == true then self.cache = self:snapshot() self.active = true end local paths = type(args.paths) == "table" and args.paths or {} local budget = tonumber(args.max_bytes) or READ_BUDGET local files, missing, more = {}, {}, {} for _, path in ipairs(paths) do local entry = self.cache.files[path] if not entry then table.insert(missing, path) elseif #files > 0 and #entry.body > budget then table.insert(more, path) else budget -= #entry.body table.insert(files, fileEntry(entry, true)) end end local text = string.format("Read %d files.%s%s", #files, #missing > 0 and (" Not found: " .. table.concat(missing, ", ") .. ".") or "", #more > 0 and (" " .. #more .. " more did not fit; ask again.") or "") return true, text, { session = self.session, files = files, missing = missing, more = more } end -- What changed since the last snapshot (the agent's command and anything the -- user did in Studio meanwhile). Bodies ride along up to a budget. function Mirror:delta() local before = self.cache local snap = self:snapshot() self.cache = snap local changed, removed, dirs = {}, {}, {} local budget = DELTA_BODY_BUDGET local omitted = false for _, entry in ipairs(snap.list) do local old = before and before.files[entry.path] if not old or old.hash ~= entry.hash or old.id ~= entry.id then local withBody = budget >= #entry.body if withBody then budget -= #entry.body else omitted = true end table.insert(changed, fileEntry(entry, withBody)) end end if before then for path in pairs(before.files) do if not snap.files[path] then table.insert(removed, path) end end for path in pairs(before.dirs) do if not snap.dirs[path] then table.insert(removed, path) end end end table.sort(removed) for path, hash in pairs(snap.dirs) do if not before or before.dirs[path] ~= hash then table.insert(dirs, { p = path, h = hash }) end end table.sort(dirs, function(a, b) return a.p < b.p end) return { session = self.session, changed = changed, removed = removed, dirs = dirs, bodies_omitted = omitted or nil, } end function Mirror.describeDelta(delta) if #delta.changed == 0 and #delta.removed == 0 then return "Files: no changes." end local names = {} for i, entry in ipairs(delta.changed) do if i > 20 then table.insert(names, "… " .. (#delta.changed - 20) .. " more") break end table.insert(names, entry.p) end local text = "Files changed (" .. #delta.changed .. "): " .. table.concat(names, ", ") if #delta.removed > 0 then local gone = {} for i, path in ipairs(delta.removed) do if i > 20 then table.insert(gone, "… " .. (#delta.removed - 20) .. " more") break end table.insert(gone, path) end text ..= "\nFiles removed (" .. #delta.removed .. "): " .. table.concat(gone, ", ") end return text end -- Apply --------------------------------------------------------------------------- local function childList(node) local names = {} for i, child in ipairs(node.children) do if i > 15 then table.insert(names, "…") break end local main = Mirror.filePaths(child) table.insert(names, child.dir and (child.logical:match("[^/]+$") .. "/") or main:match("[^/]+$")) end return #names > 0 and table.concat(names, ", ") or "nothing" end local function resolve(ctx, logical) local inst = ctx.alias[logical] if inst and inst.Parent ~= nil then return inst end local node = ctx.tree.index[logical] if node and node.inst.Parent ~= nil then return node.inst end return nil end local function notFound(ctx, logical) local parent = logical while true do parent = Codec.splitLogical(parent) if not parent then return string.format('no object at "%s"', logical) end local node = ctx.tree.index[parent] if node then return string.format('no object at "%s" (%s/ has: %s)', logical, parent, childList(node)) end end end local function bodyOf(self, op) local body = op.body if body == nil then body = op.content end if type(body) == "string" then local ok, decoded = pcall(self.hooks.jsonDecode, body) if not ok or type(decoded) ~= "table" then return nil, "the file is not valid JSON: " .. tostring(decoded) end return decoded end if type(body) ~= "table" then return nil, "body (the file's JSON) is required" end return body end local function setProp(ctx, inst, prop, value) local old = readProp(inst, prop) writeProp(inst, prop, value) table.insert(ctx.undo, function() writeProp(inst, prop, old == nil and NONE or old) end) end local BLOCKED_PROPS = { Name = "the name comes from the file path; rename with move", Parent = "the parent comes from the file path; use move", ClassName = 'set "class" at the top level of the file', Source = "a script's source is its .luau file", } -- Applies a file body to an instance: props, attributes and tags. `full` -- means the body is the whole file, so missing entries go back to defaults. -- All values are validated before anything changes. Returns changed names or -- nil, errors. function Mirror:applyBody(ctx, inst, body, full) local errors, sets, changed = {}, {}, {} local props = body.props if props ~= nil and type(props) ~= "table" then return nil, { '"props" must be an object' } end props = props or {} local refCtx = { resolve = function(path) local parsed = Codec.parsePath(path) if not parsed then return nil, "bad path " .. Codec.show(path) end local target = resolve(ctx, parsed.logical) if not target then return nil, notFound(ctx, parsed.logical) end return target end, } local function refPath(target) local node = ctx.tree.byInst[target] return node and node.logical end local names = {} for prop in pairs(props) do table.insert(names, prop) end table.sort(names) for _, prop in ipairs(names) do local json = props[prop] if type(prop) ~= "string" then table.insert(errors, "property names must be strings") elseif BLOCKED_PROPS[prop] then table.insert(errors, prop .. ": " .. BLOCKED_PROPS[prop]) else local exists, current, kind = probe(inst, prop) if not exists then local hint = Codec.suggest(prop, self:propsOf(inst)) table.insert(errors, string.format('unknown property "%s" on %s%s', prop, inst.ClassName, hint and (' — did you mean "' .. hint .. '"?') or "")) else local ok, value = Codec.decodeValue(kind, json, current, refCtx) local plain = value if plain == NONE then plain = nil end if not ok then table.insert(errors, prop .. ": " .. value) elseif kind == "Instance" then -- Compare identity: the target may be new in this diff and have no path yet. if plain ~= current then table.insert(sets, { prop, value }) end elseif Codec.encodeValue(plain, refPath) ~= Codec.encodeValue(current, refPath) then table.insert(sets, { prop, value }) end end end end if full then local defaults = self:defaults(inst) if defaults then for _, prop in ipairs(self:propsOf(inst)) do if props[prop] == nil and not Codec.isAlways(inst, prop) and defaults.raw[prop] ~= nil then local ok, current = pcall(readProp, inst, prop) local external = typeof(current) == "Instance" and not ctx.tree.byInst[current] if ok and not external and Codec.encodeValue(current, refPath) ~= defaults.frag[prop] then table.insert(sets, { prop, defaults.raw[prop], true }) end end end end end -- Attributes local attributes = body.attributes if attributes ~= nil and type(attributes) ~= "table" then table.insert(errors, '"attributes" must be an object') attributes = nil end local attributeSets = {} local currentAttributes = inst:GetAttributes() for name, json in pairs(attributes or {}) do local ok, value = Codec.decodeAttribute(json, currentAttributes[name]) if not ok then table.insert(errors, "attribute " .. tostring(name) .. ": " .. value) elseif Codec.encodeAttribute(value) ~= Codec.encodeAttribute(currentAttributes[name]) then table.insert(attributeSets, { name, value }) end end if full then for name in pairs(currentAttributes) do if not attributes or attributes[name] == nil then table.insert(attributeSets, { name, nil }) end end end -- Tags local tags = body.tags local tagAdds, tagRemoves = {}, {} if tags ~= nil then if type(tags) ~= "table" then table.insert(errors, '"tags" must be a list of strings') tags = nil else for _, tag in ipairs(tags) do if type(tag) ~= "string" then table.insert(errors, '"tags" must be a list of strings') tags = nil break end end end end local CollectionService = game:GetService("CollectionService") if tags or full then local wanted, have = {}, {} for _, tag in ipairs(tags or {}) do wanted[tag] = true end for _, tag in ipairs(CollectionService:GetTags(inst)) do have[tag] = true if not wanted[tag] then table.insert(tagRemoves, tag) end end for tag in pairs(wanted) do if not have[tag] then table.insert(tagAdds, tag) end end end if #errors > 0 then return nil, errors end table.sort(sets, function(a, b) local pa, pb = FIRST_PROPS[a[1]] or 99, FIRST_PROPS[b[1]] or 99 if pa ~= pb then return pa < pb end return a[1] < b[1] end) for _, set in ipairs(sets) do local ok, err = pcall(setProp, ctx, inst, set[1], set[2]) if not ok then return nil, { string.format("%s: can't set it (%s)", set[1], tostring(err):gsub("^.-:%d+: ", "")) } end if not set[3] then self:learnProp(inst, set[1]) end table.insert(changed, set[3] and (set[1] .. " (reset)") or set[1]) end for _, set in ipairs(attributeSets) do local old = inst:GetAttribute(set[1]) local ok, err = pcall(function() inst:SetAttribute(set[1], set[2]) end) if not ok then return nil, { "attribute " .. tostring(set[1]) .. ": " .. tostring(err) } end table.insert(ctx.undo, function() inst:SetAttribute(set[1], old) end) table.insert(changed, "@" .. set[1] .. (set[2] == nil and " (removed)" or "")) end for _, tag in ipairs(tagAdds) do CollectionService:AddTag(inst, tag) table.insert(ctx.undo, function() CollectionService:RemoveTag(inst, tag) end) table.insert(changed, "#" .. tag) end for _, tag in ipairs(tagRemoves) do CollectionService:RemoveTag(inst, tag) table.insert(ctx.undo, function() CollectionService:AddTag(inst, tag) end) table.insert(changed, "#" .. tag .. " (removed)") end return changed end local function catalogClasses() local names = {} for _, entry in ipairs(Codec.CATALOG) do table.insert(names, entry[1]) end for _, extra in ipairs({ "Folder", "Model", "Frame", "WedgePart", "CornerWedgePart", "StringValue", "NumberValue", "BoolValue", "IntValue", "ObjectValue" }) do table.insert(names, extra) end return names end function Mirror:writeJson(ctx, op, parsed, existing) local body, err = bodyOf(self, op) if not body then return false, err end if existing then if body.class ~= nil and body.class ~= existing.ClassName then return false, string.format('it is a %s; changing the class to %s is not supported — delete the file and create a new one', existing.ClassName, tostring(body.class)) end if existing:IsA("LuaSourceContainer") then return false, "it is a script; write its .luau file or .meta.json instead" end local changed, errors = self:applyBody(ctx, existing, body, true) if not changed then return false, table.concat(errors, "; ") end local warning if body.bounds ~= nil then local node = ctx.tree.byInst[existing] local cached = node and self.cache and self.cache.files[filePaths(node)] local was = cached and cached.body:match('"bounds": (%b{})') if was and Codec.encodeJson(body.bounds) ~= was then warning = "bounds is computed and read-only; change Position/Size (or a model's Pivot) to move or resize" end end return true, #changed > 0 and ("updated: " .. table.concat(changed, ", ")) or "no changes", warning end local parentLogical, segment = Codec.splitLogical(parsed.logical) if not parentLogical then return false, "services can't be created" end local parent = resolve(ctx, parentLogical) if not parent then return false, notFound(ctx, parentLogical) .. "; create the folder first (its init.json)" end if type(body.class) ~= "string" then return false, '"class" is required to create an object, e.g. {"class": "Part", "props": {...}}' end if SCRIPT_SUFFIX[body.class] then return false, "scripts are .server.luau, .client.luau or .luau files, not .json" end local okNew, inst = pcall(Instance.new, body.class) if not okNew or not inst then local hint = Codec.suggest(body.class, catalogClasses()) return false, string.format('unknown or non-creatable class "%s"%s', body.class, hint and (' — did you mean "' .. hint .. '"?') or "") end inst.Name = Codec.decodeName(segment) local changed, errors = self:applyBody(ctx, inst, body, false) if not changed then inst:Destroy() return false, table.concat(errors, "; ") end inst.Parent = parent table.insert(ctx.undo, function() inst.Parent = nil end) ctx.alias[parsed.logical] = inst return true, "created " .. body.class end function Mirror:writeScript(ctx, op, parsed, existing) local source = op.source if source == nil then source = op.content end if type(source) ~= "string" then return false, "source (the script text) is required" end local inst = existing local action if existing then if not existing:IsA("LuaSourceContainer") then return false, string.format("it is a %s, not a script", existing.ClassName) end if existing.ClassName ~= parsed.class then return false, string.format("it is a %s; its file ends in %s (move it to change the kind)", existing.ClassName, SCRIPT_SUFFIX[existing.ClassName]) end local old = existing.Source if old == source then return true, "no changes" end self.hooks.setSource(existing, source) table.insert(ctx.undo, function() self.hooks.setSource(existing, old) end) action = "updated source" else local parentLogical, segment = Codec.splitLogical(parsed.logical) local parent = parentLogical and resolve(ctx, parentLogical) if not parent then return false, (parentLogical and notFound(ctx, parentLogical) or "a script needs a service folder") .. "; create the folder first" end inst = Instance.new(parsed.class) inst.Name = Codec.decodeName(segment) inst.Source = source inst.Parent = parent table.insert(ctx.undo, function() inst.Parent = nil end) ctx.alias[parsed.logical] = inst action = "created " .. parsed.class end local warnings = {} local placement = self.hooks.placementWarning(inst) if placement then table.insert(warnings, placement) end local syntax = self.hooks.compileCheck(source, inst.Name) if syntax then table.insert(warnings, "SYNTAX ERROR: " .. syntax) end return true, action, #warnings > 0 and table.concat(warnings, " ") or nil end function Mirror:writeMeta(ctx, op, parsed, existing) if not existing then return false, notFound(ctx, parsed.logical) .. "; write the script's .luau file first" end if not existing:IsA("LuaSourceContainer") then return false, string.format("it is a %s; .meta.json is only for scripts — write its .json file", existing.ClassName) end local body, err if op.op == "delete" then body = {} else body, err = bodyOf(self, op) if not body then return false, err end end local changed, errors = self:applyBody(ctx, existing, body, true) if not changed then return false, table.concat(errors, "; ") end return true, #changed > 0 and ("updated: " .. table.concat(changed, ", ")) or "no changes" end function Mirror:applyOp(ctx, op) if type(op) ~= "table" then return false, "each op is an object like {op = \"write\", path = ..., body = ...}" end local kind = op.op or op.type local parsed = Codec.parsePath(op.path) if not parsed then return false, "path is required" end local existing = resolve(ctx, parsed.logical) if kind == "write" or kind == "create" or kind == "update" then if kind == "create" and existing then return false, "it already exists; use write or update" end if kind == "update" and not existing then return false, notFound(ctx, parsed.logical) end if parsed.kind == "json" then return self:writeJson(ctx, op, parsed, existing) elseif parsed.kind == "script" then return self:writeScript(ctx, op, parsed, existing) elseif parsed.kind == "meta" then return self:writeMeta(ctx, op, parsed, existing) end return false, "path must end in .json, .meta.json, .server.luau, .client.luau or .luau" elseif kind == "delete" then if parsed.kind == "meta" then return self:writeMeta(ctx, op, parsed, existing) end if not existing then return false, notFound(ctx, parsed.logical) end if existing.Parent == game then return false, "services can't be deleted" end local oldParent = existing.Parent existing.Parent = nil table.insert(ctx.undo, function() existing.Parent = oldParent end) ctx.tree = self:walk() ctx.alias = {} return true, "deleted" elseif kind == "move" or kind == "rename" then if not existing then return false, notFound(ctx, parsed.logical) end if existing.Parent == game then return false, "services can't be moved" end local target = Codec.parsePath(op.to) if not target then return false, '"to" (the new path) is required' end if target.kind == "script" and existing.ClassName ~= target.class then return false, string.format("it is a %s; its file would end in %s", existing.ClassName, SCRIPT_SUFFIX[existing.ClassName] or ".json") end if (target.kind == "json" or target.kind == "meta") and existing:IsA("LuaSourceContainer") then return false, "a script's path ends in .server.luau, .client.luau or .luau" end local parentLogical, segment = Codec.splitLogical(target.logical) local newParent = parentLogical and resolve(ctx, parentLogical) if not newParent then return false, parentLogical and notFound(ctx, parentLogical) or "can't move to the top level" end if newParent == existing or newParent:IsDescendantOf(existing) then return false, "can't move an object into itself" end local oldName, oldParent = existing.Name, existing.Parent existing.Name = Codec.decodeName(segment) existing.Parent = newParent table.insert(ctx.undo, function() existing.Parent = oldParent existing.Name = oldName end) ctx.tree = self:walk() ctx.alias = { [target.logical] = existing } return true, "moved to " .. target.logical end return false, 'unknown op "' .. tostring(kind) .. '" (use write, create, update, delete or move)' end -- Instance at a mirror path ("Workspace/Map/Tree~2.json"), or nil, error. function Mirror:resolvePath(path) local parsed = Codec.parsePath(path) if not parsed then return nil, "path is required" end local tree = self:walk() local node = tree.index[parsed.logical] if node then return node.inst end return nil, notFound({ tree = tree }, parsed.logical) end -- args.ops: list of { op = "write" | "create" | "update" | "delete" | "move", -- path, body | content | source, to }. args.dry_run validates and rolls back. function Mirror:apply(args) args = args or {} local ops = args.ops if type(ops) ~= "table" or #ops == 0 then return false, "ops is empty — send a list like {op = \"write\", path = \"Workspace/Door.json\", body = {...}}" end if not self.cache then self.cache = self:snapshot() end self.active = true local ctx = { tree = self:walk(), alias = {}, undo = {} } local recording = self.hooks.beginRecording("Rateblox apply") local lines, warnings = {}, {} local failedAt, failure for i, op in ipairs(ops) do local ok, success, message, warning = xpcall(function() return self:applyOp(ctx, op) end, function(e) return tostring(e) end) if not ok then success, message = false, "plugin error: " .. tostring(success) end local label = string.format("%s %s", tostring(type(op) == "table" and (op.op or op.type) or "?"), tostring(type(op) == "table" and op.path or "?")) if not success then failedAt, failure = i, string.format("ops[%d] (%s) failed: %s", i, label, tostring(message)) break end table.insert(lines, label .. ": " .. tostring(message)) if warning then table.insert(warnings, label .. ": " .. warning) end end if failedAt or args.dry_run == true then for i = #ctx.undo, 1, -1 do pcall(ctx.undo[i]) end self.hooks.finishRecording(recording, false) if failedAt then local text = failure .. "\nNothing was changed" .. (failedAt > 1 and (": the " .. (failedAt - 1) .. " earlier op(s) were rolled back.") or ".") return false, text, { failed = failedAt, error = failure } end return true, "Dry run: all " .. #ops .. " op(s) are valid. Nothing was changed.", { valid = true } end self.hooks.finishRecording(recording, true) local delta = self:delta() for _, warning in ipairs(warnings) do table.insert(lines, "WARNING " .. warning) end table.insert(lines, Mirror.describeDelta(delta)) return true, table.concat(lines, "\n"), { delta = delta } end return Mirror end)() -- Generate ----------------------------------------------------------------- -- Roblox's own AI model generation for detailed props; failures come back as -- text so the agent builds the prop from parts instead. local function toolGenerateModel(args) if not GenerationService then return false, "Model generation isn't available in this Studio — search_assets and insert a matching textured mesh; keep a clearly named placeholder only if no suitable asset is available." end local prompt = tostring(args.prompt or "") if prompt == "" then return false, "prompt is empty" end local parent = workspace if typeof(args.parent) == "string" and args.parent ~= "" then local found, err = findPath(args.parent) if not found then return false, err end parent = found end local ok, model = pcall(function() return GenerationService:GenerateModelAsync( { TextPrompt = prompt }, { PredefinedSchema = tostring(args.schema or "Body1") } ) end) if not ok or typeof(model) ~= "Instance" then return false, "Generation failed: " .. tostring(model) .. " — search_assets and insert a matching textured mesh; keep a clearly named placeholder only if no suitable asset is available." end local recording = beginUndo("Rateblox generate") for _, part in ipairs(model:GetDescendants()) do if part:IsA("BasePart") then part.Anchored = true end end if typeof(args.name) == "string" and args.name ~= "" then model.Name = args.name end if model:IsA("Model") then local size = tonumber(args.size) if size and size > 0 then local extents = model:GetExtentsSize() local largest = math.max(extents.X, extents.Y, extents.Z) if largest > 0.01 then pcall(function() model:ScaleTo(size / largest) end) end end local position = args.position if typeof(position) == "table" and #position >= 3 then local target = Vector3.new(tonumber(position[1]) or 0, tonumber(position[2]) or 0, tonumber(position[3]) or 0) local yaw = math.rad(tonumber(args.rotation_y) or 0) model:PivotTo(CFrame.new(target) * CFrame.Angles(0, yaw, 0)) local cf, extents = model:GetBoundingBox() local bottom = Vector3.new(cf.Position.X, cf.Position.Y - extents.Y / 2, cf.Position.Z) model:PivotTo(model:GetPivot() + (target - bottom)) end end model.Parent = parent finishUndo(recording) local info = "Generated " .. pathOf(model) .. " [" .. model.ClassName .. "]" if model:IsA("Model") then local cf, extents = model:GetBoundingBox() info ..= " bbox " .. fmtVector(extents) .. " center " .. fmtVector(cf.Position) end return true, info end local AGENT_TOOLS = { get_tree = toolGetTree, inspect = toolInspect, read_script = toolReadScript, grep_scripts = toolGrepScripts, write_script = toolWriteScript, edit_script = toolEditScript, run_luau = toolRunLuau, delete = toolDelete, insert_asset = toolInsertAsset, playtest = toolPlaytest, screenshot = toolScreenshot, generate_model = toolGenerateModel, } -- Optional abilities this Studio has, reported to the website so the agent -- is only offered tools that work here. local CAPABILITIES = "screenshot,assets,tests,quality_v8" if GenerationService then CAPABILITIES ..= ",generate" end -- Rateblox AI · v9 tools (fragment) -- Inlined by build_v9.py at the v9 tools marker in Standalone.lua, after -- AGENT_TOOLS and CAPABILITIES exist. Adds the file-mirror commands and makes -- every command that can change the place return a file delta once the -- server has asked for a manifest. local mirror = Mirror.new({ setSource = setSource, compileCheck = compileCheck, placementWarning = placementWarning, }) local function withDelta(handler) return function(args) local success, output = handler(args) if not mirror.active then return success, output end local text, image = output, nil if typeof(output) == "table" then text, image = output.text, output.image end local okDelta, delta = pcall(function() return mirror:delta() end) if not okDelta then return success, { text = tostring(text or "") .. "\nFiles: delta failed (" .. tostring(delta) .. "); run sync_manifest.", image = image } end return success, { text = tostring(text or "") .. "\n" .. Mirror.describeDelta(delta), image = image, data = { delta = delta }, } end end for _, name in ipairs({ "write_script", "edit_script", "run_luau", "delete", "insert_asset", "generate_model" }) do if AGENT_TOOLS[name] then AGENT_TOOLS[name] = withDelta(AGENT_TOOLS[name]) end end local function structured(method) return function(args) local ok, text, data = method(mirror, args) return ok, { text = text, data = data } end end AGENT_TOOLS.sync_manifest = structured(mirror.manifest) AGENT_TOOLS.sync_read = structured(mirror.read) AGENT_TOOLS.apply_diff = structured(mirror.apply) -- capture and insert_asset take mirror paths ("Workspace/Map/Tree~2.json"). AGENT_TOOLS.capture = function(args) if typeof(args.path) == "string" and (args.path:find("/", 1, true) or args.path:find("%.json$")) then local inst, err = mirror:resolvePath(args.path) if not inst then return false, err end args = table.clone(args) args.instance = inst end return Screenshot.toolCapture(args) end local insertAsset = AGENT_TOOLS.insert_asset AGENT_TOOLS.insert_asset = function(args) -- v9 keeps originals in ReplicatedStorage._Assets: create missing folders. if typeof(args.parent) == "string" and args.parent ~= "" and not findPath(args.parent) then local segments = string.split(args.parent, ".") local okService, current = pcall(function() return game:GetService(segments[1]) end) if okService and current then for i = 2, #segments do local child = current:FindFirstChild(segments[i]) if not child then child = Instance.new("Folder") child.Name = segments[i] child.Parent = current end current = child end end end return insertAsset(args) end CAPABILITIES ..= ",mirror_v9,capture_1080,play_shot" local function runAgentTool(payload) local handler = AGENT_TOOLS[payload.tool] if not handler then return false, "Unknown tool " .. tostring(payload.tool) .. " — update the Rateblox plugin." end local args = typeof(payload.args) == "table" and payload.args or {} local ok, success, output = xpcall(function() return handler(args) end, function(e) return tostring(e) .. "\n" .. debug.traceback(nil, 2) end) if not ok then return false, "Plugin error while running " .. payload.tool .. ": " .. tostring(success) end -- Tools may return { text, image } (screenshot): the image travels separately. if typeof(output) == "table" then -- v9: structured data (mirror manifest, file deltas) travels as well. return success ~= false, tostring(output.text or ""), output.image, output.data end return success ~= false, tostring(output or "") end -- One id per Studio window. The server pins a run's commands to the window -- that picked up the first one, so two open places don't both run the same call. local INSTANCE_ID = HttpService:GenerateGUID(false) local function httpRequest(method, path, body) local request = { Url = apiUrl(path), Method = method, Headers = { ["Content-Type"] = "application/json", ["x-plugin-version"] = tostring(PLUGIN_VERSION), ["x-plugin-caps"] = CAPABILITIES, ["x-plugin-instance"] = INSTANCE_ID, }, } if state.code and state.code ~= "" then request.Headers["x-plugin-code"] = state.code end if body ~= nil then request.Body = HttpService:JSONEncode(body) end return HttpService:RequestAsync(request) end local function decodeBody(response) if not response or typeof(response.Body) ~= "string" or response.Body == "" then return nil end local ok, payload = pcall(function() return HttpService:JSONDecode(response.Body) end) if ok then return payload end return nil end local function saveSettings() plugin:SetSetting("ApiBase", state.apiBase) plugin:SetSetting("PairCode", state.code) end local function setDisconnected(message) state.connected = false if message then log(message) end if state.refresh then state.refresh() end end local function connectFromStudio() if state.busy then return end state.busy = true state.refresh() local ok, response = pcall(function() return httpRequest("POST", "/api/plugin/pair", {}) end) state.busy = false if not ok then setDisconnected("HTTP failed. Check API URL and that Next is running.") warn("[Rateblox]", response) return end if response.StatusCode < 200 or response.StatusCode >= 300 then setDisconnected("Pair failed: HTTP " .. tostring(response.StatusCode)) return end local payload = decodeBody(response) if not payload or typeof(payload.code) ~= "string" then setDisconnected("Pair failed: no code returned") return end state.code = payload.code state.connected = false saveSettings() log("Code " .. state.code .. " — paste it at rateblox.com/app/plugin") state.refresh() end local function disconnect() state.code = nil state.connected = false saveSettings() log("Disconnected") state.refresh() end local pushContext -- defined below; pollPairStatus needs it after linking local function pollPairStatus() if not state.code or state.connected then return end local ok, response = pcall(function() return httpRequest("GET", "/api/plugin/pair?code=" .. HttpService:UrlEncode(state.code)) end) if not ok or not response then return end local payload = decodeBody(response) if payload and payload.status == "expired" then setDisconnected("Code expired. Connect again.") state.code = nil saveSettings() return end if payload and payload.connected then state.connected = true log("Linked. Chat on rateblox.com — the agent builds here.") state.refresh() -- Push initial context so the agent sees the current selection right -- after linking, without waiting for the user to change it. task.spawn(pushContext) end end -- Selection / Workspace snapshot ------------------------------------------ -- -- Every time Studio Selection changes we upload a small JSON snapshot to -- /api/plugin/context. The chat pipeline picks it up and gives it to the -- LLM as STUDIO CONTEXT so the user can point at an object and say -- "make it bigger" / "recolor this" / "replace with a tree". local function describeInstance(inst, includeChildren) local info = { path = inst:GetFullName(), name = inst.Name, className = inst.ClassName, } if inst:IsA("BasePart") then info.size = { inst.Size.X, inst.Size.Y, inst.Size.Z } info.position = { inst.Position.X, inst.Position.Y, inst.Position.Z } info.material = inst.Material.Name info.color = { math.floor(inst.Color.R * 255 + 0.5), math.floor(inst.Color.G * 255 + 0.5), math.floor(inst.Color.B * 255 + 0.5), } info.anchored = inst.Anchored elseif inst:IsA("Model") then local pivot = inst:GetPivot() info.pivot = { pivot.X, pivot.Y, pivot.Z } local ok, extents = pcall(function() return inst:GetExtentsSize() end) if ok then info.extents = { extents.X, extents.Y, extents.Z } end if inst.PrimaryPart then info.primaryPart = inst.PrimaryPart.Name end elseif inst:IsA("LuaSourceContainer") then -- Skip the actual source (can be huge); just note existence. info.hasSource = true end if includeChildren then local kids = {} for _, child in ipairs(inst:GetChildren()) do table.insert(kids, { name = child.Name, className = child.ClassName, }) if #kids >= 20 then break end end info.children = kids end return info end local function buildContextPayload() local payload = { selection = {}, workspace = { children = {} }, place = { placeId = game.PlaceId, universeId = game.GameId, name = game.Name, }, } for _, inst in ipairs(Selection:Get()) do table.insert(payload.selection, describeInstance(inst, true)) if #payload.selection >= 8 then break end end for _, child in ipairs(workspace:GetChildren()) do if not child:IsA("Terrain") and not child:IsA("Camera") then table.insert(payload.workspace.children, { name = child.Name, className = child.ClassName, }) if #payload.workspace.children >= 40 then break end end end local camera = workspace.CurrentCamera if camera then payload.camera = { position = { camera.CFrame.Position.X, camera.CFrame.Position.Y, camera.CFrame.Position.Z }, } end if StudioService then local ok, activeScript = pcall(function() return StudioService.ActiveScript end) if ok and activeScript then payload.activeScript = activeScript:GetFullName() end end return payload end local contextPending = false local lastContextSent = 0 function pushContext() if not state.connected or contextPending then return end local now = os.clock() -- Debounce to 1s so rapid selection changes don't spam the endpoint. if now - lastContextSent < 1.0 then contextPending = true task.delay(1.0, function() contextPending = false pushContext() end) return end lastContextSent = now local ok, payload = pcall(buildContextPayload) if not ok then return end pcall(function() httpRequest("POST", "/api/plugin/context", payload) end) end local function flushLogs() if not state.connected or not state.features.logs or #state.pendingLogs == 0 then return end local lines = state.pendingLogs state.pendingLogs = {} pcall(function() httpRequest("POST", "/api/plugin/logs", { lines = lines }) end) end local function startPlaytest() local ok, err = pcall(function() plugin:StartPlaySolo() end) if ok then log("Live playtest started") return end log("playtest unavailable: " .. tostring(err)) end local function postResult(id, ok, output, image, data) for attempt = 1, 3 do local sent, response = pcall(function() return httpRequest("POST", "/api/plugin/results", { id = id, ok = ok, output = output, image = image, data = data, }) end) if sent and response and response.StatusCode >= 200 and response.StatusCode < 300 then return true end task.wait(attempt) end log("Could not send a result to the website") return false end local function pollCommands() if not state.code or not state.connected then return end -- Feature flags (log forwarding etc.) change rarely; ask every ~30 s. local path = "/api/plugin/commands" if os.clock() - (state.flagsAt or -1000) > 30 then path ..= "?flags=1" state.flagsAt = os.clock() end local ok, response = pcall(function() return httpRequest("GET", path) end) if not ok or not response then return end if response.StatusCode == 401 then state.connected = false state.refresh() return end if response.StatusCode < 200 or response.StatusCode >= 300 then return end local payload = decodeBody(response) if payload then state.serverBusy = payload.busy == true if typeof(payload.idle) == "number" then state.serverIdle = math.clamp(payload.idle, 1, 30) end end if payload and typeof(payload.features) == "table" then state.features.logs = payload.features.logs == true state.features.health = payload.features.health == true state.features.passes = payload.features.passes == true end local commands = payload and payload.commands if typeof(commands) ~= "table" or #commands == 0 then return end state.activeAt = os.clock() local legacy = {} for _, row in ipairs(commands) do state.activeAt = os.clock() local body = typeof(row) == "table" and row.payload if typeof(body) == "table" and typeof(body.tool) == "string" then state.agentBusy = true state.refresh() log("▶ " .. body.tool) local success, output, image, data = runAgentTool(body) log((success and "✓ " or "✗ ") .. body.tool) postResult(row.id, success, output, image, data) state.agentBusy = false state.refresh() elseif typeof(body) == "table" then table.insert(legacy, body) end end if #legacy == 0 then return end local recording = beginUndo("Rateblox") local applied = 0 local wantPlaytest = false for _, command in ipairs(legacy) do if command.op == "playtest" then wantPlaytest = true else local success, detail = applyCommand(command) if success then applied += 1 log((command.op or "op") .. " " .. tostring(detail)) else log("fail " .. tostring(detail)) end end end finishUndo(recording) if applied > 0 then log("Applied " .. applied .. " command(s)") end if wantPlaytest then task.defer(startPlaytest) end end local function spawnTestPart() local recording = beginUndo("Rateblox test") local success, detail = applyCommand({ op = "create", className = "Part", parent = "Workspace", name = "RatebloxPing", properties = { Anchored = true, Size = { 4, 1, 4 }, Position = { 0, 8, 0 }, Material = "Neon", Color = { 5, 125, 250 }, }, }) finishUndo(recording) log(success and ("Test part: " .. tostring(detail)) or ("Test failed: " .. tostring(detail))) end -- UI ------------------------------------------------------------------------- -- One dock widget: a pairing screen until the plugin is linked, then a chat -- with the agent right inside Studio. Settings sit behind the gear button. local UserInputService = game:GetService("UserInputService") local C = { bg = Color3.fromRGB(10, 11, 18), panel = Color3.fromRGB(19, 20, 29), surface = Color3.fromRGB(30, 31, 40), line = Color3.fromRGB(44, 46, 58), text = Color3.fromRGB(255, 255, 255), soft = Color3.fromRGB(196, 198, 208), muted = Color3.fromRGB(135, 138, 152), accent = Color3.fromRGB(0, 149, 255), accentBright = Color3.fromRGB(80, 173, 239), ok = Color3.fromRGB(30, 185, 87), bad = Color3.fromRGB(231, 72, 72), } local EXAMPLES = { "Make a simple tycoon with a dropper, a conveyor and a button to buy a second dropper", "Build an obby with 8 stages, checkpoints and kill bricks", "Add a shop GUI that sells a speed coil for 100 coins", } local ui = {} local chat = { id = plugin:GetSetting("ChatId"), runId = nil, busy = false, stopping = false, } local account = { credits = nil, plan = nil, email = nil } local function make(className, props, children) local inst = Instance.new(className) for key, value in pairs(props or {}) do if key ~= "Parent" then inst[key] = value end end for _, child in ipairs(children or {}) do child.Parent = inst end if props and props.Parent then inst.Parent = props.Parent end return inst end local function corner(radius) return make("UICorner", { CornerRadius = UDim.new(0, radius) }) end local function pad(t, r, b, l) return make("UIPadding", { PaddingTop = UDim.new(0, t), PaddingRight = UDim.new(0, r or t), PaddingBottom = UDim.new(0, b or t), PaddingLeft = UDim.new(0, l or r or t), }) end local function list(spacing, horizontal) return make("UIListLayout", { FillDirection = horizontal and Enum.FillDirection.Horizontal or Enum.FillDirection.Vertical, Padding = UDim.new(0, spacing), SortOrder = Enum.SortOrder.LayoutOrder, VerticalAlignment = horizontal and Enum.VerticalAlignment.Center or Enum.VerticalAlignment.Top, }) end local function text(props) local defaults = { BackgroundTransparency = 1, Font = Enum.Font.Gotham, TextColor3 = C.soft, TextSize = 14, TextWrapped = true, TextXAlignment = Enum.TextXAlignment.Left, TextYAlignment = Enum.TextYAlignment.Top, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, RichText = true, } for key, value in pairs(props) do defaults[key] = value end return make("TextLabel", defaults) end local function pill(label, primary, props) local button = make("TextButton", { AutoButtonColor = true, BackgroundColor3 = primary and C.accent or C.surface, BorderSizePixel = 0, Font = Enum.Font.GothamMedium, Text = label, TextColor3 = C.text, TextSize = 14, Size = UDim2.new(0, 0, 0, 34), AutomaticSize = Enum.AutomaticSize.X, }, { make("UICorner", { CornerRadius = UDim.new(1, 0) }), pad(0, 14, 0, 14) }) for key, value in pairs(props or {}) do button[key] = value end return button end local function escapeRich(value) return (tostring(value):gsub("&", "&"):gsub("<", "<"):gsub(">", ">")) end -- **bold** and `code` from agent replies -> RichText. local function richReply(value) local s = escapeRich(value) s = s:gsub("%*%*(.-)%*%*", "%1") s = s:gsub("`(.-)`", '%1') return s end local function scrollToBottom() task.defer(function() if ui.messages then ui.messages.CanvasPosition = Vector2.new(0, math.max(0, ui.messages.AbsoluteCanvasSize.Y)) end end) end local messageOrder = 0 local function nextOrder() messageOrder += 1 return messageOrder end local function addBubble(kind, body, meta) if ui.welcome then ui.welcome.Visible = false end local frame if kind == "user" then frame = make("Frame", { BackgroundColor3 = C.surface, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = nextOrder(), Parent = ui.messages, }, { corner(14), pad(10, 12) }) text({ Text = escapeRich(body), TextColor3 = C.text, Parent = frame }) else frame = make("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = nextOrder(), Parent = ui.messages, }, { list(4), pad(2, 2) }) if meta and meta ~= "" then text({ Text = meta, TextColor3 = C.muted, TextSize = 12, LayoutOrder = 1, Parent = frame }) end text({ Text = kind == "error" and escapeRich(body) or richReply(body), TextColor3 = kind == "error" and C.bad or C.text, LayoutOrder = 2, Parent = frame, }) end scrollToBottom() return frame end local function addProgress() local frame = make("Frame", { BackgroundColor3 = C.panel, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = nextOrder(), Parent = ui.messages, }, { corner(14), pad(10, 12), list(5) }) text({ Name = "Waiting", Text = "Thinking…", TextColor3 = C.accentBright, Parent = frame }) scrollToBottom() return frame end local STATUS_COLOR = { running = C.accentBright, ok = C.ok, error = C.bad } local function renderProgress(frame, events) if not frame or not frame.Parent then return end for _, child in ipairs(frame:GetChildren()) do if child:IsA("TextLabel") then child:Destroy() end end local first = math.max(1, #events - 7) for i = first, #events do local event = events[i] local color = STATUS_COLOR[event.status] or C.muted local hex = color:ToHex() local line = '● ' .. escapeRich(event.label) if event.detail and event.detail ~= "" then line ..= '\n' .. escapeRich(event.detail) .. "" end text({ Text = line, TextColor3 = event.status == "running" and C.text or C.soft, TextSize = 13, LayoutOrder = i, Parent = frame, }) end scrollToBottom() end local function setComposerBusy(busy) chat.busy = busy if ui.sendBtn then ui.sendBtn.Text = busy and (chat.stopping and "Stopping…" or "Stop") or "Send" ui.sendBtn.BackgroundColor3 = busy and C.surface or C.accent end if ui.input then ui.input.PlaceholderText = busy and "The agent is working… you can type the next step" or "Describe what to build or change…" end state.refresh() end local function refreshAccount() task.spawn(function() local ok, response = pcall(function() return httpRequest("GET", "/api/plugin/me") end) local data = ok and response and response.StatusCode == 200 and decodeBody(response) if data then account.credits = data.credits account.plan = data.plan account.email = data.email state.refresh() end end) end local function sendMessage(message) message = trim(message) if message == "" or chat.busy or not state.connected then return end ui.input.Text = "" addBubble("user", message) local progress = addProgress() chat.stopping = false setComposerBusy(true) state.activeAt = os.clock() task.spawn(function() local function finish() chat.runId = nil if progress and progress.Parent then progress:Destroy() end setComposerBusy(false) refreshAccount() end local ok, response = pcall(function() return httpRequest("POST", "/api/plugin/chat", { text = message, chatId = chat.id }) end) local body = ok and response and decodeBody(response) if not body or not body.ok then addBubble("error", (body and body.error) or "Could not reach rateblox.com. Check your internet and try again.") finish() return end chat.id = body.chatId plugin:SetSetting("ChatId", chat.id) if not body.runId then addBubble("assistant", body.reply or "Done.") finish() return end chat.runId = body.runId local failures = 0 while chat.runId do state.activeAt = os.clock() local okStep, stepResponse = pcall(function() return httpRequest("POST", "/api/plugin/chat/step", { runId = chat.runId }) end) local data = okStep and stepResponse and stepResponse.StatusCode == 200 and decodeBody(stepResponse) if data then failures = 0 if typeof(data.events) == "table" and #data.events > 0 then renderProgress(progress, data.events) end if data.status ~= "running" then local meta = data.duration or "" if tonumber(data.changes) and data.changes > 0 then meta ..= (meta ~= "" and " · " or "") .. data.changes .. (data.changes == 1 and " change" or " changes") .. " in this place" end addBubble("assistant", data.reply or "Done.", meta) if data.credits then account.credits = data.credits end break end task.wait(0.2) else failures += 1 if failures >= 6 then addBubble("error", "Lost connection to rateblox.com. The agent may still be working — open the chat on the website to see the result.") break end task.wait(2 * failures) end end finish() end) end local function stopRun() if not chat.runId or chat.stopping then return end chat.stopping = true setComposerBusy(true) local runId = chat.runId task.spawn(function() pcall(function() httpRequest("POST", "/api/plugin/chat/stop", { runId = runId }) end) end) end local function newChat() if chat.busy then return end chat.id = nil plugin:SetSetting("ChatId", nil) for _, child in ipairs(ui.messages:GetChildren()) do if child:IsA("GuiObject") and child ~= ui.welcome then child:Destroy() end end ui.welcome.Visible = true ui.input.Text = "" end local function showPage(name) ui.pairPage.Visible = name == "pair" ui.chatPage.Visible = name == "chat" ui.settingsPage.Visible = name == "settings" ui.page = name end local function buildHeader(root) local header = make("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 44), Parent = root, }, { pad(0, 14) }) text({ Text = "rateblox", TextColor3 = C.text, TextSize = 18, Font = Enum.Font.GothamBold, AutomaticSize = Enum.AutomaticSize.None, Size = UDim2.new(0.5, 0, 1, 0), TextYAlignment = Enum.TextYAlignment.Center, Parent = header, }) local statusPill = make("Frame", { AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, -34, 0.5, 0), Size = UDim2.new(0, 0, 0, 24), AutomaticSize = Enum.AutomaticSize.X, BackgroundColor3 = C.surface, Parent = header, }, { make("UICorner", { CornerRadius = UDim.new(1, 0) }), pad(0, 10), list(6, true) }) ui.statusDot = make("Frame", { Size = UDim2.new(0, 8, 0, 8), BackgroundColor3 = C.muted, LayoutOrder = 1, Parent = statusPill, }, { make("UICorner", { CornerRadius = UDim.new(1, 0) }) }) ui.statusText = text({ Text = "Not connected", TextSize = 12, TextColor3 = C.soft, AutomaticSize = Enum.AutomaticSize.XY, Size = UDim2.new(0, 0, 0, 0), TextWrapped = false, LayoutOrder = 2, Parent = statusPill, }) ui.gear = make("TextButton", { AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, 0, 0.5, 0), Size = UDim2.new(0, 26, 0, 26), BackgroundColor3 = C.surface, Text = "⚙", TextColor3 = C.soft, TextSize = 15, Font = Enum.Font.GothamMedium, Parent = header, }, { make("UICorner", { CornerRadius = UDim.new(1, 0) }) }) ui.gear.MouseButton1Click:Connect(function() if ui.page == "settings" then showPage(state.connected and "chat" or "pair") else showPage("settings") end end) make("Frame", { BackgroundColor3 = C.line, BorderSizePixel = 0, Position = UDim2.new(0, 0, 0, 44), Size = UDim2.new(1, 0, 0, 1), Parent = root, }) end local function step(parent, order, number, title, body) local row = make("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = order, Parent = parent, }) make("TextLabel", { BackgroundColor3 = C.accent, BackgroundTransparency = 0.75, Size = UDim2.new(0, 26, 0, 26), Text = tostring(number), TextColor3 = C.accentBright, Font = Enum.Font.GothamBold, TextSize = 13, Parent = row, }, { make("UICorner", { CornerRadius = UDim.new(1, 0) }) }) local col = make("Frame", { BackgroundTransparency = 1, Position = UDim2.new(0, 38, 0, 2), Size = UDim2.new(1, -38, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, Parent = row, }, { list(4) }) text({ Text = "" .. title .. "", TextColor3 = C.text, LayoutOrder = 1, Parent = col }) if body then text({ Text = body, TextColor3 = C.muted, TextSize = 13, LayoutOrder = 2, Parent = col }) end return col end local function buildPairPage(root) local page = make("ScrollingFrame", { BackgroundTransparency = 1, BorderSizePixel = 0, Position = UDim2.new(0, 0, 0, 45), Size = UDim2.new(1, 0, 1, -45), CanvasSize = UDim2.new(0, 0, 0, 0), AutomaticCanvasSize = Enum.AutomaticSize.Y, ScrollBarThickness = 4, Parent = root, }, { pad(16), list(16) }) ui.pairPage = page text({ Text = "Build your game by chatting with AI", TextColor3 = C.text, TextSize = 20, Font = Enum.Font.GothamBold, LayoutOrder = 1, Parent = page, }) text({ Text = "For creators 18 and older. The Rateblox agent builds maps, writes scripts and playtests — right in this place. Link this plugin to your account once:", TextColor3 = C.soft, LayoutOrder = 2, Parent = page, }) local s1 = step(page, 3, 1, "Create a free account", "2 free builds, no card.") local urlBox = make("TextBox", { BackgroundColor3 = C.surface, Size = UDim2.new(1, 0, 0, 34), Text = "rateblox.com/app/plugin", TextEditable = false, ClearTextOnFocus = false, Font = Enum.Font.GothamMedium, TextSize = 14, TextColor3 = C.accentBright, LayoutOrder = 3, Parent = s1, }, { corner(10) }) urlBox.TextXAlignment = Enum.TextXAlignment.Center text({ Text = "Open this address in your browser (select the text and copy it).", TextColor3 = C.muted, TextSize = 12, LayoutOrder = 4, Parent = s1 }) local s2 = step(page, 4, 2, "Get your pairing code") ui.connectBtn = pill("Get code", true, { LayoutOrder = 3, Parent = s2 }) ui.connectBtn.MouseButton1Click:Connect(connectFromStudio) ui.code = make("TextBox", { BackgroundColor3 = C.panel, Size = UDim2.new(1, 0, 0, 52), Text = "", TextEditable = false, ClearTextOnFocus = false, Font = Enum.Font.GothamBold, TextSize = 26, TextColor3 = C.text, Visible = false, LayoutOrder = 4, Parent = s2, }, { corner(12), make("UIStroke", { Color = C.accent, Thickness = 1, Transparency = 0.4 }) }) step(page, 5, 3, "Type the code on the website", "On rateblox.com/app/plugin, enter the code and press Link. This panel turns into a chat by itself.") ui.pairStatus = text({ Text = "", TextColor3 = C.muted, TextSize = 13, LayoutOrder = 6, Parent = page }) text({ Text = "If Studio asks to allow HTTP requests to rateblox.com, click Allow — that is how the plugin talks to the agent.", TextColor3 = C.muted, TextSize = 12, LayoutOrder = 7, Parent = page, }) end local function buildChatPage(root) local page = make("Frame", { BackgroundTransparency = 1, Position = UDim2.new(0, 0, 0, 45), Size = UDim2.new(1, 0, 1, -45), Visible = false, Parent = root, }) ui.chatPage = page local top = make("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 40), Parent = page, }, { pad(0, 14) }) ui.credits = text({ Text = "", TextColor3 = C.accentBright, TextSize = 13, AutomaticSize = Enum.AutomaticSize.None, Size = UDim2.new(0.6, 0, 1, 0), TextYAlignment = Enum.TextYAlignment.Center, Parent = top, }) local newBtn = pill("+ New chat", false, { AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, 0, 0.5, 0), Size = UDim2.new(0, 0, 0, 28), TextSize = 13, Parent = top, }) newBtn.MouseButton1Click:Connect(newChat) local composer = make("Frame", { AnchorPoint = Vector2.new(0, 1), Position = UDim2.new(0, 0, 1, 0), Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, BackgroundTransparency = 1, Parent = page, }, { pad(8, 12, 12, 12), list(6) }) local box = make("Frame", { BackgroundColor3 = C.surface, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = 1, Parent = composer, }, { corner(16), pad(10, 10, 10, 12) }) ui.input = make("TextBox", { BackgroundTransparency = 1, Size = UDim2.new(1, -78, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, MultiLine = true, TextWrapped = true, ClearTextOnFocus = false, Text = "", PlaceholderText = "Describe what to build or change…", PlaceholderColor3 = C.muted, TextColor3 = C.text, Font = Enum.Font.Gotham, TextSize = 14, TextXAlignment = Enum.TextXAlignment.Left, TextYAlignment = Enum.TextYAlignment.Top, Parent = box, }, { make("UISizeConstraint", { MinSize = Vector2.new(0, 36), MaxSize = Vector2.new(math.huge, 140) }) }) ui.sendBtn = pill("Send", true, { AnchorPoint = Vector2.new(1, 1), Position = UDim2.new(1, 0, 1, 0), Size = UDim2.new(0, 70, 0, 34), AutomaticSize = Enum.AutomaticSize.None, Parent = box, }) ui.sendBtn.MouseButton1Click:Connect(function() if chat.busy then stopRun() else sendMessage(ui.input.Text) end end) -- Enter sends, Shift+Enter makes a new line. ui.input:GetPropertyChangedSignal("Text"):Connect(function() local value = ui.input.Text if value:sub(-1) == "\n" then local shift = UserInputService:IsKeyDown(Enum.KeyCode.LeftShift) or UserInputService:IsKeyDown(Enum.KeyCode.RightShift) if not shift then ui.input.Text = value:sub(1, -2) if not chat.busy then sendMessage(ui.input.Text) end end end end) text({ Text = "Enter to send · Shift+Enter for a new line · select objects in Studio to edit them", TextColor3 = C.muted, TextSize = 11, LayoutOrder = 2, Parent = composer, }) local messages = make("ScrollingFrame", { BackgroundTransparency = 1, BorderSizePixel = 0, Position = UDim2.new(0, 0, 0, 40), Size = UDim2.new(1, 0, 1, -140), CanvasSize = UDim2.new(0, 0, 0, 0), AutomaticCanvasSize = Enum.AutomaticSize.Y, ScrollBarThickness = 4, ScrollingDirection = Enum.ScrollingDirection.Y, Parent = page, }, { pad(4, 14, 8, 14), list(10) }) ui.messages = messages local function fit() messages.Size = UDim2.new(1, 0, 1, -(40 + composer.AbsoluteSize.Y)) end composer:GetPropertyChangedSignal("AbsoluteSize"):Connect(fit) fit() local welcome = make("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, LayoutOrder = 0, Parent = messages, }, { list(8) }) ui.welcome = welcome text({ Text = "What should we build?", TextColor3 = C.text, TextSize = 18, Font = Enum.Font.GothamBold, LayoutOrder = 1, Parent = welcome }) text({ Text = "Describe a game, a mechanic or a fix. The agent reads this place, builds, writes scripts, playtests and fixes errors. Every step can be undone with Ctrl+Z.", TextColor3 = C.soft, TextSize = 13, LayoutOrder = 2, Parent = welcome, }) for i, example in ipairs(EXAMPLES) do local button = make("TextButton", { AutoButtonColor = true, BackgroundColor3 = C.panel, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y, Text = example, TextWrapped = true, TextXAlignment = Enum.TextXAlignment.Left, TextColor3 = C.soft, Font = Enum.Font.Gotham, TextSize = 13, LayoutOrder = 2 + i, Parent = welcome, }, { corner(12), pad(9, 12), make("UIStroke", { Color = C.line, Thickness = 1 }) }) button.MouseButton1Click:Connect(function() ui.input.Text = example ui.input:CaptureFocus() end) end end local function buildSettingsPage(root) local page = make("ScrollingFrame", { BackgroundTransparency = 1, BorderSizePixel = 0, Position = UDim2.new(0, 0, 0, 45), Size = UDim2.new(1, 0, 1, -45), CanvasSize = UDim2.new(0, 0, 0, 0), AutomaticCanvasSize = Enum.AutomaticSize.Y, ScrollBarThickness = 4, Visible = false, Parent = root, }, { pad(16), list(12) }) ui.settingsPage = page text({ Text = "Settings", TextColor3 = C.text, TextSize = 18, Font = Enum.Font.GothamBold, LayoutOrder = 1, Parent = page }) ui.accountLine = text({ Text = "", TextColor3 = C.soft, TextSize = 13, LayoutOrder = 2, Parent = page }) text({ Text = "Server", TextColor3 = C.muted, TextSize = 12, LayoutOrder = 3, Parent = page }) local urlBox = make("TextBox", { BackgroundColor3 = C.surface, Size = UDim2.new(1, 0, 0, 34), Text = state.apiBase, ClearTextOnFocus = false, Font = Enum.Font.Gotham, TextSize = 13, TextColor3 = C.text, TextXAlignment = Enum.TextXAlignment.Left, LayoutOrder = 4, Parent = page, }, { corner(10), pad(0, 10) }) urlBox.FocusLost:Connect(function() state.apiBase = trim(urlBox.Text) if state.apiBase == "" then state.apiBase = DEFAULT_API urlBox.Text = DEFAULT_API end saveSettings() end) local row = make("Frame", { BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 34), LayoutOrder = 5, Parent = page }, { list(8, true) }) local testBtn = pill("Test part", false, { Parent = row }) testBtn.MouseButton1Click:Connect(spawnTestPart) ui.disconnectBtn = pill("Disconnect", false, { Parent = row }) ui.disconnectBtn.MouseButton1Click:Connect(function() disconnect() showPage("pair") end) text({ Text = "Activity", TextColor3 = C.muted, TextSize = 12, LayoutOrder = 6, Parent = page }) ui.log = text({ Text = "", TextColor3 = C.muted, TextSize = 12, Font = Enum.Font.Code, LayoutOrder = 7, Parent = page }) text({ Text = "Rateblox plugin v" .. PLUGIN_VERSION .. " · rateblox.com · Not affiliated with Roblox Corporation.", TextColor3 = C.muted, TextSize = 11, LayoutOrder = 8, Parent = page, }) end local function createUi() local root = make("Frame", { BackgroundColor3 = C.bg, BorderSizePixel = 0, Size = UDim2.fromScale(1, 1), Parent = widget, }) buildHeader(root) buildPairPage(root) buildChatPage(root) buildSettingsPage(root) showPage(state.connected and "chat" or "pair") end local wasConnected = false local waitingDots = 0 function state.refresh() if not ui.statusText then return end local working = state.agentBusy or chat.busy if state.connected then ui.statusDot.BackgroundColor3 = working and C.accentBright or C.ok ui.statusText.Text = working and "Agent working" or "Connected" elseif state.code then ui.statusDot.BackgroundColor3 = C.accentBright ui.statusText.Text = "Waiting for code" else ui.statusDot.BackgroundColor3 = C.muted ui.statusText.Text = "Not connected" end -- Pairing screen ui.code.Visible = state.code ~= nil and not state.connected ui.code.Text = state.code or "" ui.connectBtn.Text = state.busy and "Getting code…" or (state.code and "New code" or "Get code") if state.code and not state.connected then ui.pairStatus.Text = "Waiting for you to enter " .. state.code .. " on the website" .. string.rep(".", waitingDots % 4) else ui.pairStatus.Text = "" end -- Chat header if account.credits ~= nil then ui.credits.Text = account.credits .. (account.credits == 1 and " credit" or " credits") .. (account.plan and (" · " .. account.plan) or "") else ui.credits.Text = "" end ui.accountLine.Text = state.connected and ("Linked" .. (account.email and (" to " .. escapeRich(account.email)) or "") .. ". Chats from Studio also appear on rateblox.com.") or "Not linked yet." ui.disconnectBtn.Visible = state.code ~= nil ui.log.Text = table.concat(state.logLines, "\n") -- Switch screens when the link state changes. if state.connected ~= wasConnected then wasConnected = state.connected if ui.page ~= "settings" then showPage(state.connected and "chat" or "pair") end if state.connected then refreshAccount() end end end createUi() state.refresh() removeTestScripts() -- Animate the "waiting" dots while a code is pending. task.spawn(function() while running do if state.code and not state.connected then waitingDots += 1 state.refresh() end task.wait(0.6) end end) LogService.MessageOut:Connect(function(message, messageType) if not state.features.logs then return end table.insert(state.pendingLogs, tostring(messageType) .. " " .. tostring(message)) while #state.pendingLogs > 20 do table.remove(state.pendingLogs, 1) end end) toolbarButton.Click:Connect(function() widget.Enabled = not widget.Enabled end) Selection.SelectionChanged:Connect(pushContext) task.spawn(function() while running do -- One failing poll must never stop the loop. local ok, err = pcall(function() if state.code and not state.connected then -- Always check: a code restored after a Studio restart must reconnect -- even if the panel is closed. pollPairStatus() elseif state.connected then pollCommands() flushLogs() end end) if not ok then warn("[Rateblox]", err) task.wait(2) end local interval = PAIR_POLL_SECONDS if state.connected then local sinceActive = os.clock() - (state.activeAt or -1000) local idle = state.serverIdle or POLL_IDLE_SECONDS if chat.busy or state.serverBusy or sinceActive < ACTIVE_WINDOW_SECONDS then interval = POLL_ACTIVE_SECONDS elseif sinceActive < QUIET_AFTER_SECONDS then interval = idle else interval = math.max(idle, POLL_QUIET_SECONDS) end end task.wait(interval) end end) -- Refresh credits now and then while linked. task.spawn(function() while running do task.wait(60) if state.connected and not chat.busy then refreshAccount() end end end) if state.code then log("Reconnecting with " .. state.code .. "…") else -- First run: show the panel with a code already in it. The request also lets -- the setup page notice the install (and Studio's allow-HTTP prompt appears -- now, not after a click most people never made). widget.Enabled = true task.spawn(connectFromStudio) end return { applyCommand = applyCommand, }