--!strict --[[ Screenshots, so an agent can look at the place instead of inferring it. Everything else this server exposes reads the data model: names, properties, numbers. None of that answers "does it look right", which for a 3D medium is most of the question. A part can sit at the correct position, anchored, the right size, and still be inside a wall. The path is four steps, none of them optional. `CaptureService` hands back a temporary content id rather than pixels; `AssetService` turns that id into an EditableImage; the image gives up raw RGBA; and PNG and base64 are written by hand because the engine has neither. See Png.luau. ]] local AssetService = game:GetService("AssetService") local CaptureService = game:GetService("CaptureService") local EncodingService = game:GetService("EncodingService") local Players = game:GetService("Players") local RunService = game:GetService("RunService") local Dispatch = require(script.Parent.Parent.Dispatch) local Emulation = require(script.Parent.Parent.Emulation) local Paths = require(script.Parent.Parent.Paths) local Png = require(script.Parent.Parent.Png) -- Wide enough to read a GUI label, small enough that the encode stays quick and -- the reply does not dominate the conversation it is part of. local DEFAULT_WIDTH = 800 local MAX_WIDTH = 1600 local MIN_WIDTH = 160 -- The callback has never taken close to this. It exists so a capture that never -- calls back fails with something an agent can act on rather than hanging the -- session until the request deadline. local CAPTURE_TIMEOUT = 10 -- Zstd level. 3 is its default and already gets most of the win on screen -- content; the higher levels cost Studio's main thread for a few percent. local COMPRESSION_LEVEL = 3 local Capture = {} --[[ How far two channel values may differ and still count as the same colour. Generous, because a surface that looks flat is not mathematically flat: PNG round-tripping and Studio's own subtle gradients move a byte or two. ]] local FLAT_SPREAD = 6 --[[ Fraction of sampled pixels that must match before the frame counts as empty. Not 100%. This is the number two wrong versions of this check were missing: a viewport showing nothing is never perfectly uniform -- Studio leaves its settings gear and a loading mark in the corner, and those few hundred pixels were enough to make an "is every pixel the same" test answer no. 99% leaves room for that furniture while still being far below anything with a scene in it: a horizon, a part, a skybox gradient or a GUI all move well past 1%. ]] local FLAT_SHARE = 0.99 --[[ Every Nth pixel is looked at, not every pixel. A 1656x674 capture is 1.1M pixels, and walking all of them in Luau to answer a yes/no question costs more than the screenshot did. Sampling one in 16 is 11000 samples over a full frame -- far more than enough to notice that something is drawn, and it cannot miss a scene, because a scene is not 16 pixels. ]] local SAMPLE_STRIDE = 16 --[[ Whether the capture is empty: one colour, give or take the corner furniture. Two earlier versions of this got it wrong in the same direction, and both shipped a picture of nothing described as a picture of something. The first asked "is every pixel black". Studio only returns pure black when it is not rendering at all; when the 3D view is simply not the active tab, the capture is the script editor's background -- a dark navy around 26-35 -- which sailed past a threshold of 8. The second asked "is every pixel the same colour". Closer, and still no: the empty viewport keeps Studio's settings gear in the top-right corner, so a handful of pixels differ and the answer came back no again. The picture was 99% one colour and the test wanted 100%. So the question is a proportion, not an absolute. What is being detected is "nothing was drawn into this", and a frame with anything in it -- a part, a horizon, a midnight skybox, a GUI -- puts far more than 1% of its pixels somewhere else. ]] local function isFlat(rgb: buffer): boolean local length = buffer.len(rgb) if length < 3 then return true end local red = buffer.readu8(rgb, 0) local green = buffer.readu8(rgb, 1) local blue = buffer.readu8(rgb, 2) local step = 3 * SAMPLE_STRIDE local samples = 0 local matches = 0 for offset = 0, length - 3, step do samples += 1 if math.abs(buffer.readu8(rgb, offset) - red) <= FLAT_SPREAD and math.abs(buffer.readu8(rgb, offset + 1) - green) <= FLAT_SPREAD and math.abs(buffer.readu8(rgb, offset + 2) - blue) <= FLAT_SPREAD then matches += 1 end end return samples > 0 and (matches / samples) >= FLAT_SHARE end -- Only a content id comes back over the remote, so this is the time to start a -- LocalScript and take one shot, not to move any pixels. local CLIENT_TIMEOUT = 25 --[[ Screenshots during a playtest, taken by the client because only the client may take them. `CaptureService:CaptureScreenshot` refuses outright in the playtest's server session -- "can only be called on the client" -- and the edit session, which could call it, has stopped rendering its own data model and times out. Both sessions this plugin can reach are therefore the wrong one, and for a long time that was written down here as "screenshots are impossible mid-playtest". They are not. The client is reachable the same way `input` reaches it: parent a LocalScript into the player's PlayerGui and let it report back over a RemoteEvent. The client cannot make HTTP requests, but it does not need to -- the server session it replicates to already holds an open bridge. The client only takes the shot. It cannot read it: `CaptureService` hands back a temporary texture id, and `CreateEditableImageAsync` refuses those at script identity -- "cannot currently create editable image from temporary texture id" -- which no amount of client-side code gets around. The id is not client-local, though. It names a texture in the Studio process, and the *editor* session's plugin runs there with plugin identity, where the same call opens it without complaint. So the picture is taken in one session and read in the other, and the pixels never cross a RemoteEvent at all -- which also removes the size ceiling that a client-side encode had to stay under. See `Capture.playtestId` and `Capture.decode`; `screenshot` in the MCP server drives both halves. ]] local CLIENT_SOURCE = [==[ local CaptureService = game:GetService("CaptureService") local RunService = game:GetService("RunService") local relay = script local report = relay:WaitForChild("Report", 10) if report == nil then return end -- Resolve in this camera/PlayerGui, never in the server or editor DataModel. local region = nil if relay:GetAttribute("Path") ~= "" then local resolved, bounds = pcall(function() local reference = relay:WaitForChild("Runtime", 10) assert(reference, "Runtime reference did not replicate") local deadline = os.clock() + 10 while not reference.Value and os.clock() < deadline do task.wait() end assert(reference.Value, "Runtime module did not replicate") return require(reference.Value).call("capture.region", { path = relay:GetAttribute("Path") }) end) if not resolved then report:FireServer({ ok = false, reason = if typeof(bounds) == "table" then bounds.message else tostring(bounds) }) return end region = bounds.region end -- CaptureScreenshot leaves out every BillboardGui -- including the default -- ProximityPrompt, which is one -- while drawing ScreenGuis normally (measured, -- and the same gap Roblox's own MCP has: DevForum topic 4736693). So for the -- one frame of the shot, each billboard on screen is redrawn into a ScreenGui -- at the rect the engine draws it at, and removed straight after. Scripts in -- the copies are stripped so nothing runs twice. Depth is not reproduced: a -- billboard behind a wall is drawn anyway, which the caption says. local overlay = nil local billboards = 0 local billboardError = nil local overlayOk, overlayErr = pcall(function() local camera = workspace.CurrentCamera local player = game:GetService("Players").LocalPlayer overlay = Instance.new("ScreenGui") overlay.Name = "MCPBillboardOverlay" overlay.IgnoreGuiInset = true overlay.ResetOnSpawn = false -- Lowest, because billboards are drawn in the 3D pass, under every ScreenGui. overlay.DisplayOrder = -2147483648 local function anchor(gui) local subject = gui.Adornee or gui.Parent if subject and subject:IsA("BasePart") then return subject.CFrame, subject.Size end if subject and subject:IsA("Attachment") then return subject.WorldCFrame, Vector3.zero end if subject and subject:IsA("Model") then return subject:GetBoundingBox() end return nil, nil end local function redraw(gui) if not gui:IsA("BillboardGui") or not gui.Enabled then return end local frame, extents = anchor(gui) local size = gui.AbsoluteSize if not frame or size.X <= 0 or size.Y <= 0 then return end local view = camera.CFrame local world = frame.Position + gui.StudsOffsetWorldSpace + view:VectorToWorldSpace(gui.StudsOffset) + view:VectorToWorldSpace(gui.ExtentsOffset * extents / 2) local point, visible = camera:WorldToViewportPoint(world) if not visible then return end if gui.MaxDistance > 0 and (view.Position - world).Magnitude > gui.MaxDistance then return end local holder = Instance.new("Frame") holder.BackgroundTransparency = 1 holder.Position = UDim2.fromOffset(point.X - size.X / 2 + gui.SizeOffset.X * size.X, point.Y - size.Y / 2 - gui.SizeOffset.Y * size.Y) holder.Size = UDim2.fromOffset(size.X, size.Y) holder.ClipsDescendants = gui.ClipsDescendants for _, child in gui:GetChildren() do if not child:IsA("BaseScript") then local copied, copy = pcall(child.Clone, child) if copied and copy then for _, inner in copy:GetDescendants() do if inner:IsA("BaseScript") then inner:Destroy() end end copy.Parent = holder end end end holder.Parent = overlay billboards += 1 end for _, root in { workspace, player:FindFirstChildOfClass("PlayerGui") } do if root then for _, item in root:GetDescendants() do local drawn, why = pcall(redraw, item) if not drawn and billboardError == nil then billboardError = item:GetFullName() .. ": " .. tostring(why) end end end end if billboards > 0 then overlay.Parent = player:FindFirstChildOfClass("PlayerGui") end end) if not overlayOk then billboardError = tostring(overlayErr) end -- One rendered frame before the shot. The RemoteEvent is already a child of -- this script when it lands, so `WaitForChild` returns at once and the capture -- would otherwise happen on the frame the relay arrived -- which is the frame -- least likely to have finished drawing. Roblox's own working repro for -- CaptureService waits on PreRender first, and it costs a sixtieth of a second. -- Two with an overlay, so its first frame has been laid out and drawn. pcall(function() RunService.PreRender:Wait() if billboards > 0 then RunService.PreRender:Wait() end end) local contentId = nil local okShot, shotErr = pcall(function() CaptureService:CaptureScreenshot(function(id) contentId = id end) end) if not okShot then if overlay then overlay:Destroy() end report:FireServer({ ok = false, reason = tostring(shotErr) }) return end local waited = 0 while contentId == nil and waited < 12 do task.wait(0.05) waited += 0.05 end if overlay then overlay:Destroy() end if contentId == nil then report:FireServer({ ok = false, reason = "the client did not produce a screenshot within 12s" }) return end report:FireServer({ ok = true, contentId = contentId, region = region, billboards = billboards, billboardError = billboardError }) ]==] --[[ Takes the shot and waits for the id. `CaptureService:CaptureScreenshot` answers through a callback rather than yielding, so this bridges the two: the handler is already running on its own task and is free to wait. ]] local function takeScreenshot(): string local contentId: string? = nil local failed: string? = nil local ok, err = pcall(function() CaptureService:CaptureScreenshot(function(id: string) contentId = id end) end) if not ok then failed = tostring(err) end if failed ~= nil then Dispatch.fail( "CAPTURE_REFUSED", string.format("Studio refused to take a screenshot: %s", failed) ) end --[[ A frame at a time to begin with, then a slower poll. The callback normally fires within a frame or two, and a flat 0.05s wait billed that to the agent as up to 50ms of doing nothing -- on a call whose whole point is that it is taken often. `task.wait()` resumes on the next heartbeat, so the common case now costs a frame; after a quarter of a second the shot is clearly not coming back promptly and the poll drops to the cheap interval for the rest of the timeout. ]] local waited = 0 while contentId == nil and waited < CAPTURE_TIMEOUT do waited += if waited < 0.25 then task.wait() else task.wait(0.05) end if contentId == nil then Dispatch.fail( "CAPTURE_TIMEOUT", string.format("Studio did not return a screenshot within %ds.", CAPTURE_TIMEOUT), "The viewport may be hidden or Studio may be busy; try again." ) end return contentId :: any end --[[ Half a playtest screenshot: the client takes the shot, this returns its id. Deliberately does not try to read it. The editor session does that, because only plugin identity may -- see the note on CLIENT_SOURCE. ]] function Capture.playtestId(params: { [string]: any }): { [string]: any } if RunService:IsEdit() or not RunService:IsRunning() then Dispatch.fail( "NOT_RUNNING", "This is not a running playtest session, so there is no client to capture from.", "Address this at the playtest's studioId from `list_studios`." ) end local players = Players:GetPlayers() if #players == 0 then Dispatch.fail( "NO_PLAYER", "No player is in this session, so there is no client to capture from.", "Start a playtest with a character using `playtest op=\"play\"`." ) end local player: Player? = nil if params.player then for _, candidate in players do if candidate.Name == params.player then player = candidate end end if not player then Dispatch.fail("NO_PLAYER", "No player named " .. tostring(params.player)) end elseif #players == 1 then player = players[1] else Dispatch.fail("AMBIGUOUS_PLAYER", "Select a screenshot player by name.") end player = player :: Player local playerGui = player:FindFirstChildOfClass("PlayerGui") if playerGui == nil then Dispatch.fail("NO_PLAYER", string.format("%s has no PlayerGui yet.", player.Name)) end local remote = Instance.new("RemoteEvent") remote.Name = "Report" local relay = Instance.new("LocalScript") relay.Name = "MCPCaptureRelay" relay.Source = CLIENT_SOURCE relay.Archivable = false relay:SetAttribute("MCPClientRelay", true) relay:SetAttribute("Path", if params.rect then "" else params.path or "") if not params.rect and params.path then require(script.Parent.Parent.ClientRelay).attachRuntime(relay, player) end remote.Parent = relay local answer: { [string]: any }? = nil local connection = remote.OnServerEvent:Connect(function(from, payload) if from == player and answer == nil and typeof(payload) == "table" then answer = payload :: { [string]: any } end end) -- Parented last, for the same reason as the input relay: the script runs the -- instant it lands and immediately waits on the RemoteEvent. relay.Parent = playerGui local deadline = os.clock() + CLIENT_TIMEOUT while answer == nil and os.clock() < deadline do task.wait(0.05) end connection:Disconnect() relay:Destroy() if answer == nil then Dispatch.fail( "CAPTURE_TIMEOUT", string.format("The client did not answer within %ds.", CLIENT_TIMEOUT), "The client may still be loading; try again once the character has spawned." ) end local result = answer :: { [string]: any } if result.ok ~= true then Dispatch.fail("CAPTURE_REFUSED", string.format("The client could not capture: %s", tostring(result.reason))) end return { contentId = tostring(result.contentId), region = result.region, device = Emulation.deviceId(), billboards = if typeof(result.billboards) == "number" and result.billboards > 0 then result.billboards else nil, -- A redraw that failed is said, so a missing billboard is not read as absent. billboardError = if typeof(result.billboardError) == "string" then result.billboardError else nil, } end --[[ Widest image the engine will create for us. `AssetService:CreateEditableImage` caps at 1024 on either axis, and the `width` parameter goes to 1600. Above the cap there is no destination to draw into, so those calls fall back to the Luau loop rather than failing -- a slower big screenshot, not a missing one. ]] local ENGINE_SCALE_LIMIT = 1024 --[[ Drops alpha. RGBA in, RGB out, no scaling. A screenshot has no alpha worth keeping and carrying it would add a quarter to every byte that follows, so the channel goes here rather than on the wire. Copied a word at a time rather than a byte at a time. Each 32-bit read takes a whole pixel and each write lands three bytes further on, so the alpha byte spills one place into the next pixel's slot -- where the next iteration overwrites it. Only the final pixel has nothing following it, which is what the one byte of slack on the buffer is for; the result is then trimmed to the exact length so nothing downstream has to know about it. Worth the trick: measured on a 799x359 frame, 16ms a byte at a time against 6-10ms this way, for output verified identical. That is most of what the engine-side downscale costs, on a call agents make constantly. ]] local function stripAlpha(rgba: buffer, width: number, height: number): buffer local pixels = width * height local length = pixels * 3 local wide = buffer.create(length + 1) for index = 0, pixels - 1 do buffer.writeu32(wide, index * 3, buffer.readu32(rgba, index * 4)) end local out = buffer.create(length) buffer.copy(out, 0, wide, 0, length) return out end --[[ Downscales on the engine's side instead of in Luau. This is about the picture, not the clock. `SamplingMode` defaults to bilinear, so a 2x reduction averages the pixels it merges; the Luau loop it replaces took every other row and column and dropped the rest, which is how one-pixel GUI text and thin edges vanished from screenshots that existed to answer "does this look right". It was written expecting to be faster too, and it is not -- that guess is recorded here because the measurement is the useful part. On a 1370x616 frame scaled to 799: the old full-resolution read costs about 1ms, not the large number the 4.4MB of RGBA suggests, and the Luau downscale that follows runs in 19-22ms all in. `DrawImageTransformed` alone is 16-17ms of engine time. With `stripAlpha` doing the rest a word at a time, this path lands around 23ms -- a couple of milliseconds behind, on a call whose round trip is measured in hundreds. Paying that for legible text is the trade; pretending it was free would have been the mistake. Returns nil when the engine cannot do it -- an older Studio without `CreateEditableImage`, or a target past ENGINE_SCALE_LIMIT -- and the caller falls back. ]] local function resample( source: any, sourceWidth: number, sourceHeight: number, targetWidth: number ): (buffer?, number, number) local scale = math.min(1, targetWidth / sourceWidth) local outWidth = math.max(1, math.floor(sourceWidth * scale)) local outHeight = math.max(1, math.floor(sourceHeight * scale)) if outWidth > ENGINE_SCALE_LIMIT or outHeight > ENGINE_SCALE_LIMIT then return nil, 0, 0 end local ok, rgb = pcall(function() local canvas = (AssetService :: any):CreateEditableImage({ Size = Vector2.new(outWidth, outHeight), }) --[[ Position is the centre, not the corner: `DrawImageTransformed` places the source's PIVOT at the position given, and the pivot defaults to the middle of the source image. Passing Vector2.zero here puts three quarters of the frame off-canvas. ]] local okDraw = pcall(function() canvas:DrawImageTransformed( Vector2.new(outWidth / 2, outHeight / 2), Vector2.new(scale, scale), 0, source, { CombineType = Enum.ImageCombineType.Overwrite, SamplingMode = Enum.ResamplerMode.Default, } ) end) if not okDraw then canvas:Destroy() error("draw failed", 0) end local pixels = canvas:ReadPixelsBuffer(Vector2.zero, canvas.Size) -- Freed here rather than left to the collector: an EditableImage holds -- its pixels outside Luau's heap, and a screenshot every few seconds -- would otherwise pile them up for as long as Studio stays open. canvas:Destroy() return stripAlpha(pixels, outWidth, outHeight) end) if not ok then return nil, 0, 0 end return rgb :: buffer, outWidth, outHeight end --[[ Turns a content id into pixels on the wire. Split out from `screenshot` because a playtest shot is taken in one session and read in another: the id is all that travels, and this is the reading half, which needs plugin identity and therefore an editor session. ]] -- In capture pixels, or in viewport pixels when `viewportWidth` says so. export type Region = { x: number, y: number, width: number, height: number, viewportWidth: number? } --[[ The full-resolution path: the captured pixels themselves, cropped to `region` when one is given, compressed for the wire and scaled down in Node. The engine resample below is bilinear, which only averages the pixels it merges at exactly 2x -- beyond that it skips source pixels, and a 1661-wide viewport scaled to 800 lost thin edges and broke small GUI text apart. Node scales with a box filter instead, where every source pixel counts. Measured cost of sending the full frame: ~60ms of a ~550ms call, most of which is the engine's own capture. Returns nil when this Studio cannot do it (no EncodingService), so the caller falls back to the engine resample. ]] local function encodeFull( image: any, sourceWidth: number, sourceHeight: number, width: number, context: string, region: Region? ): { [string]: any }? local x, y, w, h = 0, 0, sourceWidth, sourceHeight if region then -- Viewport and capture pixels differ under display scaling. local ratio = if region.viewportWidth then sourceWidth / region.viewportWidth else 1 region = { x = region.x * ratio, y = region.y * ratio, width = region.width * ratio, height = region.height * ratio, } x = math.clamp(math.floor(region.x), 0, sourceWidth) y = math.clamp(math.floor(region.y), 0, sourceHeight) w = math.min(math.ceil(region.width), sourceWidth - x) h = math.min(math.ceil(region.height), sourceHeight - y) if w < 1 or h < 1 then Dispatch.fail( "REGION_OFFSCREEN", string.format( "The region (%d, %d, %d x %d) is outside the %d x %d viewport.", region.x, region.y, region.width, region.height, sourceWidth, sourceHeight ), "Aim the camera at it first with `viewport op=\"focus\"`, then take the screenshot." ) end end local ok, result = pcall(function() local rgba = image:ReadPixelsBuffer(Vector2.new(x, y), Vector2.new(w, h)) local rgb = stripAlpha(rgba, w, h) local compressed = (EncodingService :: any):CompressBuffer( rgb, (Enum :: any).CompressionAlgorithm.Zstd, COMPRESSION_LEVEL ) return { rgb = rgb, packed = buffer.tostring((EncodingService :: any):Base64Encode(compressed)), } end) if not ok then return nil end return { encoding = "zstd-rgb", data = result.packed, width = w, height = h, -- Node scales to this with a box filter; see above. scaleTo = width, sourceWidth = sourceWidth, sourceHeight = sourceHeight, region = if region then { x = x, y = y, width = w, height = h } else nil, rawBytes = buffer.len(result.rgb), bytes = #result.packed, context = context, black = isFlat(result.rgb), device = Emulation.deviceId(), } end local function encode(contentId: string, width: number, context: string, region: Region?): { [string]: any } local okImage, image = pcall(function() return AssetService:CreateEditableImageAsync(Content.fromUri(contentId)) end) if not okImage then Dispatch.fail( "CAPTURE_UNREADABLE", string.format("The screenshot could not be opened for reading: %s", tostring(image)) ) end local size = (image :: any).Size local sourceWidth = math.floor(size.X) local sourceHeight = math.floor(size.Y) local full = encodeFull(image, sourceWidth, sourceHeight, width, context, region) if full then pcall(function() (image :: any):Destroy() end) return full end if region then Dispatch.fail( "REGION_UNSUPPORTED", "This Studio build cannot crop a screenshot (EncodingService is missing).", "Take the full screenshot instead, or update Studio." ) end local rgb, outWidth, outHeight = resample(image, sourceWidth, sourceHeight, width) if rgb == nil then --[[ Both arguments are required. Called with none it reports "expects 2 arguments" rather than defaulting to the whole image, which is worth stating because every other read on this object takes none. ]] local okPixels, pixels = pcall(function() return (image :: any):ReadPixelsBuffer(Vector2.zero, size) end) if not okPixels then Dispatch.fail( "CAPTURE_UNREADABLE", string.format("The screenshot's pixels could not be read: %s", tostring(pixels)) ) end rgb, outWidth, outHeight = Png.downscaleToRgb(pixels, sourceWidth, sourceHeight, width) end -- On the scaled image, not the source: the question is whether anything was -- drawn, which survives the downscale, and it is a quarter of the pixels to -- walk. local black = isFlat(rgb :: buffer) --[[ Raw pixels, compressed by the engine, assembled into a PNG by Node. The plugin used to write the whole PNG itself, and could only write a bad one: Studio has no deflate, so `Png.encode` emitted zlib *stored* blocks -- the format's "compression not applied" escape hatch -- and every screenshot travelled and landed at full uncompressed size. It was also base64-ing by hand, a table lookup per byte in Luau. `EncodingService` has both, natively. Zstd is the only algorithm the engine exposes and PNG cannot use it, so the split is: the plugin compresses the pixels for the wire, and Node -- which has real zlib -- decompresses and writes a properly deflated PNG. The side with the compressor does the compressing. Guarded rather than assumed: EncodingService is recent, and a Studio without it should cost a bigger screenshot, not a failed one. ]] local okPacked, packed = pcall(function() local compressed = (EncodingService :: any):CompressBuffer( rgb :: buffer, (Enum :: any).CompressionAlgorithm.Zstd, COMPRESSION_LEVEL ) return buffer.tostring((EncodingService :: any):Base64Encode(compressed)) end) if okPacked then return { encoding = "zstd-rgb", data = packed, width = outWidth, height = outHeight, sourceWidth = sourceWidth, sourceHeight = sourceHeight, rawBytes = buffer.len(rgb :: buffer), bytes = #(packed :: string), context = context, black = black, device = Emulation.deviceId(), } end local png = Png.encode(rgb :: buffer, outWidth, outHeight) return { encoding = "png", data = Png.base64(png), width = outWidth, height = outHeight, sourceWidth = sourceWidth, sourceHeight = sourceHeight, bytes = buffer.len(png), context = context, black = black, device = Emulation.deviceId(), } end -- Room left around a zoomed subject, as a share of its size, so its edges are -- visible rather than cut exactly at the frame. local REGION_PADDING = 0.08 --[[ Where on screen a GUI element or a piece of the world is, in viewport pixels. GUI positions leave out the top inset, so it is added back; a 3D subject is the screen box around its bounding box's eight corners. A subject partly behind the camera has no honest box, so that is refused with the fix. ]] local regionFor = require(script.Parent.Parent.ScreenRegion).of function Capture.screenshot(params: { [string]: any }): { [string]: any } --[[ Refused rather than half-done. A playtest screenshot needs two sessions and only the MCP server can see both, so failing here with the reason is better than returning the editor's own idle viewport, which looks like a valid answer to "what does the player see". ]] if RunService:IsRunning() and not RunService:IsEdit() then Dispatch.fail( "NEEDS_EDITOR_SESSION", "A playtest screenshot is taken on the client and read in the editor session, " .. "so it cannot be served from the playtest server alone.", "Use the `screenshot` tool, which drives both halves. It needs the editor " .. "session for the same place to still be connected." ) end local width = math.clamp(tonumber(params.width) or DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH) -- Before the capture, so a path that does not resolve costs no screenshot. local region = regionFor(params) return encode(takeScreenshot(), width, if RunService:IsEdit() then "edit" else "playtest", region) end --[[ Reads a content id someone else captured. Only the editor session can do this, and only because the temporary texture is owned by the Studio process rather than by the session that made it. ]] function Capture.decode(params: { [string]: any }): { [string]: any } local contentId = params.contentId if typeof(contentId) ~= "string" or contentId == "" then Dispatch.fail("BAD_PARAMS", "decode needs a `contentId`.") end local width = math.clamp(tonumber(params.width) or DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH) local context = if typeof(params.context) == "string" then params.context else "playtest client" -- Only `rect` here: a path would have to be resolved in the client's view. local region = if typeof(params.rect) == "string" then regionFor({ rect = params.rect }) else params.region return encode(contentId :: string, width, context, region) end function Capture.register() Dispatch.registerAll("capture", { screenshot = Capture.screenshot, playtestId = Capture.playtestId, decode = Capture.decode, }) end return Capture