@el4cteo/rbx-studio-mcp 0.3.1 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +20 -26
  2. package/dist/index.js +1 -1
  3. package/dist/lib/format.js +18 -2
  4. package/dist/lib/format.js.map +1 -1
  5. package/dist/tools/api.js +20 -1
  6. package/dist/tools/api.js.map +1 -1
  7. package/dist/tools/debug.js +18 -8
  8. package/dist/tools/debug.js.map +1 -1
  9. package/dist/tools/discover.js +5 -0
  10. package/dist/tools/discover.js.map +1 -1
  11. package/dist/tools/input.js +38 -5
  12. package/dist/tools/input.js.map +1 -1
  13. package/dist/tools/instances.js +49 -2
  14. package/dist/tools/instances.js.map +1 -1
  15. package/dist/tools/perf.js +9 -8
  16. package/dist/tools/perf.js.map +1 -1
  17. package/dist/tools/scripts.js +10 -1
  18. package/dist/tools/scripts.js.map +1 -1
  19. package/package.json +1 -1
  20. package/plugin/src/Config.luau +1 -1
  21. package/plugin/src/Console.luau +301 -117
  22. package/plugin/src/Mirror.luau +126 -0
  23. package/plugin/src/Serialize.luau +43 -3
  24. package/plugin/src/ThemePicker.luau +458 -0
  25. package/plugin/src/Themes/Aurora.luau +161 -0
  26. package/plugin/src/Themes/Blueprint.luau +213 -0
  27. package/plugin/src/Themes/Draw.luau +177 -0
  28. package/plugin/src/Themes/Lattice.luau +304 -0
  29. package/plugin/src/Themes/Nebula.luau +182 -0
  30. package/plugin/src/Themes/Observatory.luau +200 -0
  31. package/plugin/src/Themes/Orbit.luau +268 -0
  32. package/plugin/src/Themes/Phosphor.luau +200 -0
  33. package/plugin/src/Themes/Theme.luau +121 -0
  34. package/plugin/src/Themes/Void.luau +266 -0
  35. package/plugin/src/Themes/init.luau +116 -0
  36. package/plugin/src/Undo.luau +29 -0
  37. package/plugin/src/Visuals.luau +838 -907
  38. package/plugin/src/handlers/Device.luau +21 -1
  39. package/plugin/src/handlers/Discover.luau +75 -1
  40. package/plugin/src/handlers/Exec.luau +17 -5
  41. package/plugin/src/handlers/Input.luau +583 -493
  42. package/plugin/src/handlers/Instances.luau +35 -3
  43. package/plugin/src/init.server.luau +209 -11
@@ -1,493 +1,583 @@
1
- --!strict
2
- --[[
3
- Synthetic keyboard and mouse input, delivered to a running playtest.
4
-
5
- This was written off twice before it was found. `VirtualInputManager` needs
6
- the RobloxScript capability and `VirtualUser` needs LocalUser -- both closed
7
- to plugins, both the obvious place to look, and both dead ends. The third
8
- door is `UserInputService:CreateVirtualInput()`, which is Security None and
9
- hands back a `VirtualInput` with SendKey, SendMouseButton, SendMousePosition,
10
- SendMouseDelta, SendTextInput and SendPointerAction. Measured: a synthetic W
11
- walked the character 25.6 studs.
12
-
13
- The catch is where it has to happen. Input belongs to the DataModel that
14
- creates the VirtualInput, and the character is driven by the *client*. Called
15
- from the playtest's server session -- the one this plugin can reach -- every
16
- send succeeds and nothing moves. That is the worst possible failure: a green
17
- result for an action that did nothing, which is the exact class of bug this
18
- project keeps finding in other people's tools.
19
-
20
- So the server does not send the input. It parents a LocalScript into the
21
- player's PlayerGui, which runs on their client, and that script does the
22
- sending and reports back over a RemoteEvent. Nothing here reports success
23
- until the client says it happened.
24
- ]]
25
-
26
- local HttpService = game:GetService("HttpService")
27
- local Players = game:GetService("Players")
28
- local ReplicatedStorage = game:GetService("ReplicatedStorage")
29
- local RunService = game:GetService("RunService")
30
-
31
- local Dispatch = require(script.Parent.Parent.Dispatch)
32
- local Emulation = require(script.Parent.Parent.Emulation)
33
-
34
- local Input = {}
35
-
36
- -- Long enough for a slow client to start the script and work through a batch,
37
- -- short enough that a client which never reports fails inside a tool call.
38
- local ACK_TIMEOUT = 20
39
- local MAX_STEPS = 40
40
- local MAX_HOLD = 10
41
-
42
- --[[
43
- The relay, fixed rather than generated.
44
-
45
- Building Luau from the request would mean pasting caller-supplied names into
46
- source, so a key called `A) print("owned") --` would run. The plan travels as
47
- JSON on an attribute instead, and this script only ever indexes enums by name
48
- and checks the result.
49
-
50
- It also draws the pointer. Synthetic mouse input moves nothing the eye can
51
- follow -- the click lands and the only evidence is whatever it changed -- so
52
- anyone watching a playtest being driven sees results with no cause. The
53
- cursor is a real ImageLabel tweened to each target before the click is sent,
54
- which makes the agent's aim visible and, when it misses, visibly wrong.
55
- ]]
56
- local RELAY_SOURCE = [==[
57
- local UserInputService = game:GetService("UserInputService")
58
- local HttpService = game:GetService("HttpService")
59
-
60
- local relay = script
61
- local report = relay:WaitForChild("Report", 10)
62
- if report == nil then
63
- return
64
- end
65
-
66
- local ok, plan = pcall(function()
67
- return HttpService:JSONDecode(relay:GetAttribute("Plan"))
68
- end)
69
- if not ok or typeof(plan) ~= "table" then
70
- report:FireServer({ ok = false, reason = "the plan did not decode on the client" })
71
- return
72
- end
73
-
74
- local virtual = UserInputService:CreateVirtualInput()
75
- local performed = {}
76
-
77
- --[[
78
- Where the client actually read the last pointer event, against where it was
79
- aimed.
80
-
81
- Measured, because it cannot be derived. A click sent at (300,300) with a
82
- Galaxy S25 Ultra emulated is read at (253,242); the same click with an
83
- iPhone 16 in portrait is read at (300,183). Constant per configuration, and
84
- fitting no formula over device resolution, viewport size and GUI inset that
85
- holds for both -- the horizontal term matches (device - viewport) / 2 and
86
- the vertical term does not.
87
-
88
- So it is reported rather than predicted. The caller aims once, sees where it
89
- landed, and corrects; that works on every device, in both orientations, and
90
- with no device emulated at all, which no formula here has managed.
91
-
92
- `InputBegan` rather than `GetMouseLocation`: the latter is frozen at the
93
- last real click under touch emulation -- twenty samples across two sent
94
- moves never changed it -- and is offset from `InputBegan.Position` by the
95
- GUI inset besides. Phone emulation also turns MouseEnabled off and delivers
96
- these as Touch, so both types are watched.
97
- ]]
98
- local landed = nil
99
- local aimed = nil
100
- UserInputService.InputBegan:Connect(function(input)
101
- local kind = input.UserInputType
102
- if aimed ~= nil and (kind == Enum.UserInputType.MouseButton1 or kind == Enum.UserInputType.Touch) then
103
- landed = { sent = aimed, seen = { x = input.Position.X, y = input.Position.Y } }
104
- end
105
- end)
106
-
107
- --[[
108
- The on-screen pointer.
109
-
110
- Roblox's own arrow texture, not one of ours. Shipping the pixels was tried
111
- first -- base64 in a StringValue, decoded into an EditableImage -- and it
112
- cannot work: writing to an EditableImage is gated on an experience-level
113
- security setting that is off by default, so every place would have to opt in
114
- before the cursor appeared. `WritePixelsBuffer` says so outright ("go to the
115
- Security Tab in Experience Settings to enable this API") and the failure was
116
- invisible, because a cursor that fails to build just does not show up.
117
-
118
- An image id needs neither. The primary is a public Creator Store pointer;
119
- `rbxasset://` content, which ships inside the client itself and can never
120
- fail to resolve, is the fallback for the day that asset is moderated or
121
- pulled. Both are tinted and given a halo -- the point is to show what the
122
- agent is aiming at, and a pointer indistinguishable from the player's own
123
- would show nothing.
124
- ]]
125
- local ARROW = "rbxassetid://15718988485"
126
- local ARROW_FALLBACK = "rbxasset://textures/Cursors/KeyboardMouse/ArrowCursor.png"
127
- local ACCENT = Color3.fromRGB(255, 162, 0)
128
-
129
- --[[
130
- Where each arrow's tip sits inside its own image, as a fraction of it.
131
-
132
- Both measured rather than guessed, and they are nothing alike -- the store
133
- image is padded and the built-in one puts its tip at the exact centre of a
134
- 64x64 square -- so the anchor has to change with the image. Getting this
135
- wrong is the quiet kind of wrong: the pointer still glides to the right
136
- area and still ripples, it just lies about which pixel it hit.
137
-
138
- The tint only works on a filled arrow. An outline-only cursor multiplies to
139
- black and looks like the image failed to load.
140
- ]]
141
- local HOTSPOT = Vector2.new(0.36, 0.32)
142
- local HOTSPOT_FALLBACK = Vector2.new(0.484, 0.5)
143
-
144
- local cursor = nil
145
- local cursorGui = nil
146
-
147
- local function makeCursor()
148
- if relay:GetAttribute("Cursor") ~= true then
149
- return
150
- end
151
-
152
- local gui = Instance.new("ScreenGui")
153
- gui.Name = "MCPCursor"
154
- gui.ResetOnSpawn = false
155
- gui.IgnoreGuiInset = true
156
- gui.DisplayOrder = 2147483647
157
- gui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
158
-
159
- local label = Instance.new("ImageLabel")
160
- label.Name = "Pointer"
161
- label.BackgroundTransparency = 1
162
- label.Size = UDim2.fromOffset(52, 52)
163
- label.AnchorPoint = HOTSPOT
164
- label.Position = UDim2.fromScale(0.5, 0.5)
165
- label.Image = ARROW
166
- label.ImageColor3 = ACCENT
167
- label.ZIndex = 100
168
- label.Parent = gui
169
-
170
- -- Centred on the tip rather than on the image, and behind the arrow, so the
171
- -- pointer stays findable against a busy scene without hiding what it targets.
172
- local halo = Instance.new("Frame")
173
- halo.Name = "Halo"
174
- halo.AnchorPoint = Vector2.new(0.5, 0.5)
175
- halo.Position = UDim2.fromScale(HOTSPOT.X, HOTSPOT.Y)
176
- halo.Size = UDim2.fromOffset(32, 32)
177
- halo.BackgroundColor3 = ACCENT
178
- halo.BackgroundTransparency = 0.45
179
- halo.BorderSizePixel = 0
180
- halo.ZIndex = 99
181
- local round = Instance.new("UICorner")
182
- round.CornerRadius = UDim.new(1, 0)
183
- round.Parent = halo
184
- halo.Parent = label
185
-
186
- gui.Parent = relay.Parent
187
- cursorGui = gui
188
- cursor = label
189
-
190
- -- Swapped only if the store image really did not arrive. Checked after the
191
- -- GUI is live so the pointer is on screen either way, and never blocks.
192
- task.spawn(function()
193
- local waited = 0
194
- while not label.IsLoaded and waited < 3 do
195
- task.wait(0.1)
196
- waited += 0.1
197
- end
198
- if not label.IsLoaded then
199
- label.Image = ARROW_FALLBACK
200
- -- The tip moves with the image, so the anchor and the halo move too.
201
- label.AnchorPoint = HOTSPOT_FALLBACK
202
- halo.Position = UDim2.fromScale(HOTSPOT_FALLBACK.X, HOTSPOT_FALLBACK.Y)
203
- end
204
- end)
205
- end
206
-
207
- -- Moves the pointer there and waits, so the click is seen to be aimed.
208
- local function moveCursor(x, y, duration)
209
- if cursor == nil then
210
- return
211
- end
212
- local TweenService = game:GetService("TweenService")
213
- local goal = UDim2.fromOffset(x, y)
214
- local tween = TweenService:Create(
215
- cursor,
216
- TweenInfo.new(duration, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
217
- { Position = goal }
218
- )
219
- tween:Play()
220
- tween.Completed:Wait()
221
- end
222
-
223
- -- A ring that expands and fades where the click landed.
224
- local function pulse()
225
- if cursor == nil then
226
- return
227
- end
228
- local TweenService = game:GetService("TweenService")
229
- local ring = Instance.new("Frame")
230
- ring.AnchorPoint = Vector2.new(0.5, 0.5)
231
- ring.Position = UDim2.new(cursor.Position.X.Scale, cursor.Position.X.Offset, cursor.Position.Y.Scale, cursor.Position.Y.Offset)
232
- ring.Size = UDim2.fromOffset(8, 8)
233
- ring.BackgroundColor3 = ACCENT
234
- ring.BackgroundTransparency = 0.25
235
- ring.BorderSizePixel = 0
236
- ring.ZIndex = 99
237
- local corner = Instance.new("UICorner")
238
- corner.CornerRadius = UDim.new(1, 0)
239
- corner.Parent = ring
240
- ring.Parent = cursorGui
241
-
242
- local grow = TweenService:Create(
243
- ring,
244
- TweenInfo.new(0.35, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
245
- { Size = UDim2.fromOffset(52, 52), BackgroundTransparency = 1 }
246
- )
247
- grow:Play()
248
- grow.Completed:Connect(function()
249
- ring:Destroy()
250
- end)
251
- end
252
-
253
- makeCursor()
254
-
255
- for _, step in plan do
256
- local kind = step.kind
257
- if kind == "key" then
258
- local code = Enum.KeyCode[step.key]
259
- if step.action == "release" then
260
- virtual:SendKey(false, code, false)
261
- else
262
- virtual:SendKey(true, code, false)
263
- if step.action ~= "press" then
264
- task.wait(step.hold or 0.08)
265
- virtual:SendKey(false, code, false)
266
- end
267
- end
268
- elseif kind == "move" then
269
- moveCursor(step.x, step.y, 0.3)
270
- virtual:SendMousePosition(Vector2.new(step.x, step.y))
271
- elseif kind == "click" then
272
- local button = Enum.UserInputType[step.button or "MouseButton1"]
273
- local at = Vector2.new(step.x, step.y)
274
- aimed = { x = step.x, y = step.y }
275
- -- Travel first, then click. Teleporting the pointer onto the target at
276
- -- the same instant it fires looks like nothing happened at all.
277
- moveCursor(step.x, step.y, 0.3)
278
- pulse()
279
- virtual:SendMousePosition(at)
280
- virtual:SendMouseButton(at, button, true, 0)
281
- if step.action ~= "press" then
282
- task.wait(step.hold or 0.05)
283
- virtual:SendMouseButton(at, button, false, 0)
284
- end
285
- elseif kind == "text" then
286
- virtual:SendTextInput(step.text)
287
- end
288
- table.insert(performed, kind)
289
- if step.after ~= nil and step.after > 0 then
290
- task.wait(step.after)
291
- end
292
- end
293
-
294
- -- Left up for a moment so the last action is still visible when the tool returns.
295
- task.wait(0.25)
296
- if cursorGui ~= nil then
297
- cursorGui:Destroy()
298
- end
299
-
300
- report:FireServer({ ok = true, performed = performed, landed = landed })
301
- ]==]
302
-
303
- local function playerFor(name: string?): Player
304
- local players = Players:GetPlayers()
305
- if #players == 0 then
306
- Dispatch.fail(
307
- "NO_PLAYER",
308
- "No player is in this session.",
309
- "Input needs a running playtest with a character. Use `playtest op=\"play\"`, "
310
- .. "then address this at the playtest's studioId."
311
- )
312
- end
313
- if name == nil or name == "" then
314
- if #players > 1 then
315
- Dispatch.fail(
316
- "AMBIGUOUS_PLAYER",
317
- string.format("%d players are in this session.", #players),
318
- "Name one with `player`."
319
- )
320
- end
321
- return players[1]
322
- end
323
- for _, player in players do
324
- if player.Name == name then
325
- return player
326
- end
327
- end
328
- Dispatch.fail("NO_PLAYER", string.format("No player named %q is in this session.", name))
329
- return players[1]
330
- end
331
-
332
- --[[
333
- Turns the request into steps the relay understands, validating names here
334
- rather than on the client where a bad one would silently do nothing.
335
- ]]
336
- local function planFrom(params: { [string]: any }): { { [string]: any } }
337
- local steps = params.steps
338
- if typeof(steps) ~= "table" or #(steps :: { any }) == 0 then
339
- Dispatch.fail("BAD_PARAMS", "input needs a `steps` array.")
340
- end
341
- if #(steps :: { any }) > MAX_STEPS then
342
- Dispatch.fail(
343
- "TOO_MANY",
344
- string.format("%d steps, over the limit of %d.", #(steps :: { any }), MAX_STEPS)
345
- )
346
- end
347
-
348
- local plan: { { [string]: any } } = {}
349
- for index, raw in steps :: { { [string]: any } } do
350
- local kind = tostring(raw.kind or "key")
351
- local step: { [string]: any } = { kind = kind }
352
-
353
- if raw.after ~= nil then
354
- step.after = math.clamp(tonumber(raw.after) or 0, 0, MAX_HOLD)
355
- end
356
- if raw.hold ~= nil then
357
- step.hold = math.clamp(tonumber(raw.hold) or 0, 0, MAX_HOLD)
358
- end
359
-
360
- if kind == "key" then
361
- local key = tostring(raw.key or "")
362
- local okKey = pcall(function()
363
- return (Enum.KeyCode :: any)[key]
364
- end)
365
- if not okKey or key == "" then
366
- Dispatch.fail(
367
- "BAD_KEY",
368
- string.format("Step %d: %q is not a KeyCode.", index, key),
369
- 'Use names from Enum.KeyCode, e.g. "W", "Space", "LeftShift", "E".'
370
- )
371
- end
372
- step.key = key
373
- step.action = tostring(raw.action or "tap")
374
- elseif kind == "click" or kind == "move" then
375
- step.x = tonumber(raw.x) or 0
376
- step.y = tonumber(raw.y) or 0
377
- if kind == "click" then
378
- local button = tostring(raw.button or "MouseButton1")
379
- local okButton = pcall(function()
380
- return (Enum.UserInputType :: any)[button]
381
- end)
382
- if not okButton then
383
- Dispatch.fail(
384
- "BAD_PARAMS",
385
- string.format("Step %d: %q is not a UserInputType.", index, button),
386
- 'Use "MouseButton1", "MouseButton2" or "MouseButton3".'
387
- )
388
- end
389
- step.button = button
390
- step.action = tostring(raw.action or "tap")
391
- end
392
- elseif kind == "text" then
393
- step.text = tostring(raw.text or "")
394
- else
395
- Dispatch.fail(
396
- "BAD_PARAMS",
397
- string.format("Step %d: unknown kind %q.", index, kind),
398
- 'Use "key", "click", "move" or "text".'
399
- )
400
- end
401
-
402
- table.insert(plan, step)
403
- end
404
- return plan
405
- end
406
-
407
- function Input.send(params: { [string]: any }): { [string]: any }
408
- --[[
409
- Refused rather than attempted. In an edit session VirtualInput does work,
410
- but it delivers into the editor's own DataModel where there is no game
411
- and no character -- so it would look like it ran and change nothing.
412
- ]]
413
- if not RunService:IsRunning() then
414
- Dispatch.fail(
415
- "NOT_RUNNING",
416
- "Input needs a running playtest; this is an edit session.",
417
- "Start one with `playtest op=\"play\"`, then address this at the playtest's studioId."
418
- )
419
- end
420
-
421
- local plan = planFrom(params)
422
- local player = playerFor(if typeof(params.player) == "string" then params.player else nil)
423
- local playerGui = player:FindFirstChildOfClass("PlayerGui")
424
- if playerGui == nil then
425
- Dispatch.fail("NO_PLAYER", string.format("%s has no PlayerGui yet.", player.Name))
426
- end
427
-
428
- local remote = Instance.new("RemoteEvent")
429
- remote.Name = "Report"
430
-
431
- local relay = Instance.new("LocalScript")
432
- relay.Name = "MCPInputRelay"
433
- relay.Source = RELAY_SOURCE
434
- relay:SetAttribute("Plan", HttpService:JSONEncode(plan))
435
- relay:SetAttribute("Cursor", params.cursor ~= false)
436
- remote.Parent = relay
437
-
438
- local answer: { [string]: any }? = nil
439
- local connection = remote.OnServerEvent:Connect(function(_from, payload)
440
- if typeof(payload) == "table" then
441
- answer = payload :: { [string]: any }
442
- end
443
- end)
444
-
445
- -- Parenting last: the script starts the moment it lands, and it waits for
446
- -- the RemoteEvent, so both must already be in place.
447
- relay.Parent = playerGui
448
-
449
- local deadline = os.clock() + ACK_TIMEOUT
450
- while answer == nil and os.clock() < deadline do
451
- task.wait(0.05)
452
- end
453
-
454
- connection:Disconnect()
455
- relay:Destroy()
456
-
457
- if answer == nil then
458
- Dispatch.fail(
459
- "NO_ACK",
460
- string.format("The client did not confirm the input within %ds.", ACK_TIMEOUT),
461
- "Nothing here can tell whether some of it was delivered. Read `character op=\"state\"` "
462
- .. "to see where things actually are."
463
- )
464
- end
465
-
466
- local result = answer :: { [string]: any }
467
- if result.ok ~= true then
468
- Dispatch.fail("INPUT_FAILED", tostring(result.reason or "the client refused the plan"))
469
- end
470
-
471
- return {
472
- delivered = true,
473
- steps = #plan,
474
- player = player.Name,
475
- performed = result.performed,
476
- --[[
477
- Named so the caller can tell why a click that was delivered still hit
478
- nothing. `landed` carries the arithmetic; this says what to turn off.
479
- ]]
480
- emulation = Emulation.summary(),
481
- -- Where the client read the last click, against where it was aimed. See
482
- -- the note in RELAY_SOURCE for why this is measured and not computed.
483
- landed = result.landed,
484
- }
485
- end
486
-
487
- function Input.register()
488
- Dispatch.registerAll("input", {
489
- send = Input.send,
490
- })
491
- end
492
-
493
- return Input
1
+ --!strict
2
+ --[[
3
+ Synthetic keyboard and mouse input, delivered to a running playtest.
4
+
5
+ This was written off twice before it was found. `VirtualInputManager` needs
6
+ the RobloxScript capability and `VirtualUser` needs LocalUser -- both closed
7
+ to plugins, both the obvious place to look, and both dead ends. The third
8
+ door is `UserInputService:CreateVirtualInput()`, which is Security None and
9
+ hands back a `VirtualInput` with SendKey, SendMouseButton, SendMousePosition,
10
+ SendMouseDelta, SendTextInput and SendPointerAction. Measured: a synthetic W
11
+ walked the character 25.6 studs.
12
+
13
+ The catch is where it has to happen. Input belongs to the DataModel that
14
+ creates the VirtualInput, and the character is driven by the *client*. Called
15
+ from the playtest's server session -- the one this plugin can reach -- every
16
+ send succeeds and nothing moves. That is the worst possible failure: a green
17
+ result for an action that did nothing, which is the exact class of bug this
18
+ project keeps finding in other people's tools.
19
+
20
+ So the server does not send the input. It parents a LocalScript into the
21
+ player's PlayerGui, which runs on their client, and that script does the
22
+ sending and reports back over a RemoteEvent. Nothing here reports success
23
+ until the client says it happened.
24
+ ]]
25
+
26
+ local HttpService = game:GetService("HttpService")
27
+ local Players = game:GetService("Players")
28
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
29
+ local RunService = game:GetService("RunService")
30
+
31
+ local Dispatch = require(script.Parent.Parent.Dispatch)
32
+ local Emulation = require(script.Parent.Parent.Emulation)
33
+
34
+ local Input = {}
35
+
36
+ -- Long enough for a slow client to start the script and work through a batch,
37
+ -- short enough that a client which never reports fails inside a tool call.
38
+ local ACK_TIMEOUT = 20
39
+ local MAX_STEPS = 40
40
+ local MAX_HOLD = 10
41
+
42
+ --[[
43
+ The relay, fixed rather than generated.
44
+
45
+ Building Luau from the request would mean pasting caller-supplied names into
46
+ source, so a key called `A) print("owned") --` would run. The plan travels as
47
+ JSON on an attribute instead, and this script only ever indexes enums by name
48
+ and checks the result.
49
+
50
+ It also draws the pointer. Synthetic mouse input moves nothing the eye can
51
+ follow -- the click lands and the only evidence is whatever it changed -- so
52
+ anyone watching a playtest being driven sees results with no cause. The
53
+ cursor is a real ImageLabel tweened to each target before the click is sent,
54
+ which makes the agent's aim visible and, when it misses, visibly wrong.
55
+ ]]
56
+ local RELAY_SOURCE = [==[
57
+ local UserInputService = game:GetService("UserInputService")
58
+ local HttpService = game:GetService("HttpService")
59
+ local Players = game:GetService("Players")
60
+
61
+ local relay = script
62
+ local report = relay:WaitForChild("Report", 10)
63
+ if report == nil then
64
+ return
65
+ end
66
+
67
+ local ok, plan = pcall(function()
68
+ return HttpService:JSONDecode(relay:GetAttribute("Plan"))
69
+ end)
70
+ if not ok or typeof(plan) ~= "table" then
71
+ report:FireServer({ ok = false, reason = "the plan did not decode on the client" })
72
+ return
73
+ end
74
+
75
+ local virtual = UserInputService:CreateVirtualInput()
76
+ local performed = {}
77
+
78
+ --[[
79
+ Where the client actually read the last pointer event, against where it was
80
+ aimed.
81
+
82
+ Measured, because it cannot be derived. A click sent at (300,300) with a
83
+ Galaxy S25 Ultra emulated is read at (253,242); the same click with an
84
+ iPhone 16 in portrait is read at (300,183). Constant per configuration, and
85
+ fitting no formula over device resolution, viewport size and GUI inset that
86
+ holds for both -- the horizontal term matches (device - viewport) / 2 and
87
+ the vertical term does not.
88
+
89
+ So it is reported rather than predicted. The caller aims once, sees where it
90
+ landed, and corrects; that works on every device, in both orientations, and
91
+ with no device emulated at all, which no formula here has managed.
92
+
93
+ Applying it automatically was tried and REVERTED, because the "constant
94
+ translation" it relies on is not general. It holds with nothing emulated
95
+ (measured (0,-58), which is exactly GuiService:GetGuiInset()) and on an
96
+ iPhone 16 in portrait ((0,-117), the same at two points 215px apart). It does
97
+ NOT hold on the same phone in LandscapeRight: there the horizontal term
98
+ inverts and scales -- seen.x fitted 919 - 1.4 * sent.x across two samples --
99
+ so subtracting a translation moved the aim further away, from 187px out to
100
+ 449px out. Sampling a better fit is not open either: synthetic MOVES report
101
+ no position at all (InputChanged never fires for SendMousePosition), so the
102
+ map can only be sampled by clicking, and probe clicks press whatever they
103
+ land on. Reporting the measurement is the honest ceiling here.
104
+
105
+ `InputBegan` rather than `GetMouseLocation`: the latter is frozen at the
106
+ last real click under touch emulation -- twenty samples across two sent
107
+ moves never changed it -- and is offset from `InputBegan.Position` by the
108
+ GUI inset besides. Phone emulation also turns MouseEnabled off and delivers
109
+ these as Touch, so both types are watched.
110
+ ]]
111
+ local landed = nil
112
+ local aimed = nil
113
+ local seenAt = nil
114
+ UserInputService.InputBegan:Connect(function(input)
115
+ local kind = input.UserInputType
116
+ if aimed ~= nil and (kind == Enum.UserInputType.MouseButton1 or kind == Enum.UserInputType.Touch) then
117
+ seenAt = { x = input.Position.X, y = input.Position.Y }
118
+ landed = { sent = aimed, seen = seenAt }
119
+ end
120
+ end)
121
+
122
+ -- Anything the caller has to be told about a step that was delivered and still
123
+ -- did nothing. Silence would otherwise read as success.
124
+ local notes = {}
125
+
126
+ --[[
127
+ The TextBox under a point, if there is one.
128
+
129
+ `GetGuiObjectsAtPosition` answers in the same space `InputBegan` reports, so
130
+ it is asked with where the click was SEEN rather than where it was sent --
131
+ those differ by the offset above, and asking with the wrong one finds
132
+ whatever happens to sit an inset away from the target.
133
+ ]]
134
+ local function textBoxAt(point)
135
+ if point == nil then
136
+ return nil
137
+ end
138
+ local playerGui = Players.LocalPlayer:FindFirstChildOfClass("PlayerGui")
139
+ if playerGui == nil then
140
+ return nil
141
+ end
142
+ local ok, hits = pcall(function()
143
+ return playerGui:GetGuiObjectsAtPosition(point.x, point.y)
144
+ end)
145
+ if not ok or typeof(hits) ~= "table" then
146
+ return nil
147
+ end
148
+ for _, object in hits do
149
+ if object:IsA("TextBox") then
150
+ return object
151
+ end
152
+ end
153
+ return nil
154
+ end
155
+
156
+ --[[
157
+ The on-screen pointer.
158
+
159
+ Roblox's own arrow texture, not one of ours. Shipping the pixels was tried
160
+ first -- base64 in a StringValue, decoded into an EditableImage -- and it
161
+ cannot work: writing to an EditableImage is gated on an experience-level
162
+ security setting that is off by default, so every place would have to opt in
163
+ before the cursor appeared. `WritePixelsBuffer` says so outright ("go to the
164
+ Security Tab in Experience Settings to enable this API") and the failure was
165
+ invisible, because a cursor that fails to build just does not show up.
166
+
167
+ An image id needs neither. The primary is a public Creator Store pointer;
168
+ `rbxasset://` content, which ships inside the client itself and can never
169
+ fail to resolve, is the fallback for the day that asset is moderated or
170
+ pulled. Both are tinted and given a halo -- the point is to show what the
171
+ agent is aiming at, and a pointer indistinguishable from the player's own
172
+ would show nothing.
173
+ ]]
174
+ local ARROW = "rbxassetid://15718988485"
175
+ local ARROW_FALLBACK = "rbxasset://textures/Cursors/KeyboardMouse/ArrowCursor.png"
176
+ local ACCENT = Color3.fromRGB(255, 162, 0)
177
+
178
+ --[[
179
+ Where each arrow's tip sits inside its own image, as a fraction of it.
180
+
181
+ Both measured rather than guessed, and they are nothing alike -- the store
182
+ image is padded and the built-in one puts its tip at the exact centre of a
183
+ 64x64 square -- so the anchor has to change with the image. Getting this
184
+ wrong is the quiet kind of wrong: the pointer still glides to the right
185
+ area and still ripples, it just lies about which pixel it hit.
186
+
187
+ The tint only works on a filled arrow. An outline-only cursor multiplies to
188
+ black and looks like the image failed to load.
189
+ ]]
190
+ local HOTSPOT = Vector2.new(0.36, 0.32)
191
+ local HOTSPOT_FALLBACK = Vector2.new(0.484, 0.5)
192
+
193
+ local cursor = nil
194
+ local cursorGui = nil
195
+
196
+ local function makeCursor()
197
+ if relay:GetAttribute("Cursor") ~= true then
198
+ return
199
+ end
200
+
201
+ local gui = Instance.new("ScreenGui")
202
+ gui.Name = "MCPCursor"
203
+ gui.ResetOnSpawn = false
204
+ gui.IgnoreGuiInset = true
205
+ gui.DisplayOrder = 2147483647
206
+ gui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
207
+
208
+ local label = Instance.new("ImageLabel")
209
+ label.Name = "Pointer"
210
+ label.BackgroundTransparency = 1
211
+ label.Size = UDim2.fromOffset(52, 52)
212
+ label.AnchorPoint = HOTSPOT
213
+ label.Position = UDim2.fromScale(0.5, 0.5)
214
+ label.Image = ARROW
215
+ label.ImageColor3 = ACCENT
216
+ label.ZIndex = 100
217
+ label.Parent = gui
218
+
219
+ -- Centred on the tip rather than on the image, and behind the arrow, so the
220
+ -- pointer stays findable against a busy scene without hiding what it targets.
221
+ local halo = Instance.new("Frame")
222
+ halo.Name = "Halo"
223
+ halo.AnchorPoint = Vector2.new(0.5, 0.5)
224
+ halo.Position = UDim2.fromScale(HOTSPOT.X, HOTSPOT.Y)
225
+ halo.Size = UDim2.fromOffset(32, 32)
226
+ halo.BackgroundColor3 = ACCENT
227
+ halo.BackgroundTransparency = 0.45
228
+ halo.BorderSizePixel = 0
229
+ halo.ZIndex = 99
230
+ local round = Instance.new("UICorner")
231
+ round.CornerRadius = UDim.new(1, 0)
232
+ round.Parent = halo
233
+ halo.Parent = label
234
+
235
+ gui.Parent = relay.Parent
236
+ cursorGui = gui
237
+ cursor = label
238
+
239
+ -- Swapped only if the store image really did not arrive. Checked after the
240
+ -- GUI is live so the pointer is on screen either way, and never blocks.
241
+ task.spawn(function()
242
+ local waited = 0
243
+ while not label.IsLoaded and waited < 3 do
244
+ task.wait(0.1)
245
+ waited += 0.1
246
+ end
247
+ if not label.IsLoaded then
248
+ label.Image = ARROW_FALLBACK
249
+ -- The tip moves with the image, so the anchor and the halo move too.
250
+ label.AnchorPoint = HOTSPOT_FALLBACK
251
+ halo.Position = UDim2.fromScale(HOTSPOT_FALLBACK.X, HOTSPOT_FALLBACK.Y)
252
+ end
253
+ end)
254
+ end
255
+
256
+ -- Moves the pointer there and waits, so the click is seen to be aimed.
257
+ local function moveCursor(x, y, duration)
258
+ if cursor == nil then
259
+ return
260
+ end
261
+ local TweenService = game:GetService("TweenService")
262
+ local goal = UDim2.fromOffset(x, y)
263
+ local tween = TweenService:Create(
264
+ cursor,
265
+ TweenInfo.new(duration, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
266
+ { Position = goal }
267
+ )
268
+ tween:Play()
269
+ tween.Completed:Wait()
270
+ end
271
+
272
+ -- A ring that expands and fades where the click landed.
273
+ local function pulse()
274
+ if cursor == nil then
275
+ return
276
+ end
277
+ local TweenService = game:GetService("TweenService")
278
+ local ring = Instance.new("Frame")
279
+ ring.AnchorPoint = Vector2.new(0.5, 0.5)
280
+ ring.Position = UDim2.new(cursor.Position.X.Scale, cursor.Position.X.Offset, cursor.Position.Y.Scale, cursor.Position.Y.Offset)
281
+ ring.Size = UDim2.fromOffset(8, 8)
282
+ ring.BackgroundColor3 = ACCENT
283
+ ring.BackgroundTransparency = 0.25
284
+ ring.BorderSizePixel = 0
285
+ ring.ZIndex = 99
286
+ local corner = Instance.new("UICorner")
287
+ corner.CornerRadius = UDim.new(1, 0)
288
+ corner.Parent = ring
289
+ ring.Parent = cursorGui
290
+
291
+ local grow = TweenService:Create(
292
+ ring,
293
+ TweenInfo.new(0.35, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
294
+ { Size = UDim2.fromOffset(52, 52), BackgroundTransparency = 1 }
295
+ )
296
+ grow:Play()
297
+ grow.Completed:Connect(function()
298
+ ring:Destroy()
299
+ end)
300
+ end
301
+
302
+ makeCursor()
303
+
304
+ for _, step in plan do
305
+ local kind = step.kind
306
+ if kind == "key" then
307
+ local code = Enum.KeyCode[step.key]
308
+ if step.action == "release" then
309
+ virtual:SendKey(false, code, false)
310
+ else
311
+ virtual:SendKey(true, code, false)
312
+ if step.action ~= "press" then
313
+ task.wait(step.hold or 0.08)
314
+ virtual:SendKey(false, code, false)
315
+ end
316
+ end
317
+ elseif kind == "move" then
318
+ moveCursor(step.x, step.y, 0.3)
319
+ virtual:SendMousePosition(Vector2.new(step.x, step.y))
320
+ elseif kind == "click" then
321
+ local button = Enum.UserInputType[step.button or "MouseButton1"]
322
+ aimed = { x = step.x, y = step.y }
323
+ seenAt = nil
324
+ local at = Vector2.new(step.x, step.y)
325
+ -- Travel first, then click. Teleporting the pointer onto the target at
326
+ -- the same instant it fires looks like nothing happened at all.
327
+ moveCursor(step.x, step.y, 0.3)
328
+ pulse()
329
+ virtual:SendMousePosition(at)
330
+ virtual:SendMouseButton(at, button, true, 0)
331
+ if step.action ~= "press" then
332
+ task.wait(step.hold or 0.05)
333
+ virtual:SendMouseButton(at, button, false, 0)
334
+ end
335
+ elseif kind == "text" then
336
+ --[[
337
+ `SendTextInput` types into the FOCUSED TextBox and does nothing at
338
+ all when there is none -- and a synthetic click does not focus one,
339
+ because Roblox drives TextBox focus from the real input path rather
340
+ than from InputBegan. Measured: clicking a TextBox and then sending
341
+ text left it empty, while CaptureFocus followed by the same call
342
+ typed correctly.
343
+
344
+ So the click is honoured as the aim and the focus is taken here. If
345
+ there is no TextBox to take it, that is said out loud: a `text` step
346
+ reported as delivered while nothing was typed is the worst of the
347
+ three outcomes.
348
+ ]]
349
+ local box = UserInputService:GetFocusedTextBox()
350
+ if box == nil then
351
+ box = textBoxAt(seenAt) or textBoxAt(aimed)
352
+ if box ~= nil then
353
+ box:CaptureFocus()
354
+ task.wait(0.05)
355
+ end
356
+ end
357
+ if box == nil then
358
+ table.insert(
359
+ notes,
360
+ "nothing was typed: no TextBox had focus and none was found under the last click. "
361
+ .. "Click the box in the same call, one step before the text."
362
+ )
363
+ else
364
+ virtual:SendTextInput(step.text)
365
+ end
366
+ end
367
+ table.insert(performed, kind)
368
+ if step.after ~= nil and step.after > 0 then
369
+ task.wait(step.after)
370
+ end
371
+ end
372
+
373
+ -- Left up for a moment so the last action is still visible when the tool returns.
374
+ task.wait(0.25)
375
+ if cursorGui ~= nil then
376
+ cursorGui:Destroy()
377
+ end
378
+
379
+ report:FireServer({
380
+ ok = true,
381
+ performed = performed,
382
+ landed = landed,
383
+ -- Sent only when there is something to say: an empty Lua table crosses as
384
+ -- an object rather than an array, and a caller iterating it would trip.
385
+ notes = if #notes > 0 then notes else nil,
386
+ })
387
+ ]==]
388
+
389
+ local function playerFor(name: string?): Player
390
+ local players = Players:GetPlayers()
391
+ if #players == 0 then
392
+ Dispatch.fail(
393
+ "NO_PLAYER",
394
+ "No player is in this session.",
395
+ "Input needs a running playtest with a character. Use `playtest op=\"play\"`, "
396
+ .. "then address this at the playtest's studioId."
397
+ )
398
+ end
399
+ if name == nil or name == "" then
400
+ if #players > 1 then
401
+ Dispatch.fail(
402
+ "AMBIGUOUS_PLAYER",
403
+ string.format("%d players are in this session.", #players),
404
+ "Name one with `player`."
405
+ )
406
+ end
407
+ return players[1]
408
+ end
409
+ for _, player in players do
410
+ if player.Name == name then
411
+ return player
412
+ end
413
+ end
414
+ Dispatch.fail("NO_PLAYER", string.format("No player named %q is in this session.", name))
415
+ return players[1]
416
+ end
417
+
418
+ --[[
419
+ Turns the request into steps the relay understands, validating names here
420
+ rather than on the client where a bad one would silently do nothing.
421
+ ]]
422
+ local function planFrom(params: { [string]: any }): { { [string]: any } }
423
+ local steps = params.steps
424
+ if typeof(steps) ~= "table" or #(steps :: { any }) == 0 then
425
+ Dispatch.fail("BAD_PARAMS", "input needs a `steps` array.")
426
+ end
427
+ if #(steps :: { any }) > MAX_STEPS then
428
+ Dispatch.fail(
429
+ "TOO_MANY",
430
+ string.format("%d steps, over the limit of %d.", #(steps :: { any }), MAX_STEPS)
431
+ )
432
+ end
433
+
434
+ local plan: { { [string]: any } } = {}
435
+ for index, raw in steps :: { { [string]: any } } do
436
+ local kind = tostring(raw.kind or "key")
437
+ local step: { [string]: any } = { kind = kind }
438
+
439
+ if raw.after ~= nil then
440
+ step.after = math.clamp(tonumber(raw.after) or 0, 0, MAX_HOLD)
441
+ end
442
+ if raw.hold ~= nil then
443
+ step.hold = math.clamp(tonumber(raw.hold) or 0, 0, MAX_HOLD)
444
+ end
445
+
446
+ if kind == "key" then
447
+ local key = tostring(raw.key or "")
448
+ local okKey = pcall(function()
449
+ return (Enum.KeyCode :: any)[key]
450
+ end)
451
+ if not okKey or key == "" then
452
+ Dispatch.fail(
453
+ "BAD_KEY",
454
+ string.format("Step %d: %q is not a KeyCode.", index, key),
455
+ 'Use names from Enum.KeyCode, e.g. "W", "Space", "LeftShift", "E".'
456
+ )
457
+ end
458
+ step.key = key
459
+ step.action = tostring(raw.action or "tap")
460
+ elseif kind == "click" or kind == "move" then
461
+ step.x = tonumber(raw.x) or 0
462
+ step.y = tonumber(raw.y) or 0
463
+ if kind == "click" then
464
+ local button = tostring(raw.button or "MouseButton1")
465
+ local okButton = pcall(function()
466
+ return (Enum.UserInputType :: any)[button]
467
+ end)
468
+ if not okButton then
469
+ Dispatch.fail(
470
+ "BAD_PARAMS",
471
+ string.format("Step %d: %q is not a UserInputType.", index, button),
472
+ 'Use "MouseButton1", "MouseButton2" or "MouseButton3".'
473
+ )
474
+ end
475
+ step.button = button
476
+ step.action = tostring(raw.action or "tap")
477
+ end
478
+ elseif kind == "text" then
479
+ step.text = tostring(raw.text or "")
480
+ else
481
+ Dispatch.fail(
482
+ "BAD_PARAMS",
483
+ string.format("Step %d: unknown kind %q.", index, kind),
484
+ 'Use "key", "click", "move" or "text".'
485
+ )
486
+ end
487
+
488
+ table.insert(plan, step)
489
+ end
490
+ return plan
491
+ end
492
+
493
+ function Input.send(params: { [string]: any }): { [string]: any }
494
+ --[[
495
+ Refused rather than attempted. In an edit session VirtualInput does work,
496
+ but it delivers into the editor's own DataModel where there is no game
497
+ and no character -- so it would look like it ran and change nothing.
498
+ ]]
499
+ if not RunService:IsRunning() then
500
+ Dispatch.fail(
501
+ "NOT_RUNNING",
502
+ "Input needs a running playtest; this is an edit session.",
503
+ "Start one with `playtest op=\"play\"`, then address this at the playtest's studioId."
504
+ )
505
+ end
506
+
507
+ local plan = planFrom(params)
508
+ local player = playerFor(if typeof(params.player) == "string" then params.player else nil)
509
+ local playerGui = player:FindFirstChildOfClass("PlayerGui")
510
+ if playerGui == nil then
511
+ Dispatch.fail("NO_PLAYER", string.format("%s has no PlayerGui yet.", player.Name))
512
+ end
513
+
514
+ local remote = Instance.new("RemoteEvent")
515
+ remote.Name = "Report"
516
+
517
+ local relay = Instance.new("LocalScript")
518
+ relay.Name = "MCPInputRelay"
519
+ relay.Source = RELAY_SOURCE
520
+ relay:SetAttribute("Plan", HttpService:JSONEncode(plan))
521
+ relay:SetAttribute("Cursor", params.cursor ~= false)
522
+
523
+ remote.Parent = relay
524
+
525
+ local answer: { [string]: any }? = nil
526
+ local connection = remote.OnServerEvent:Connect(function(_from, payload)
527
+ if typeof(payload) == "table" then
528
+ answer = payload :: { [string]: any }
529
+ end
530
+ end)
531
+
532
+ -- Parenting last: the script starts the moment it lands, and it waits for
533
+ -- the RemoteEvent, so both must already be in place.
534
+ relay.Parent = playerGui
535
+
536
+ local deadline = os.clock() + ACK_TIMEOUT
537
+ while answer == nil and os.clock() < deadline do
538
+ task.wait(0.05)
539
+ end
540
+
541
+ connection:Disconnect()
542
+ relay:Destroy()
543
+
544
+ if answer == nil then
545
+ Dispatch.fail(
546
+ "NO_ACK",
547
+ string.format("The client did not confirm the input within %ds.", ACK_TIMEOUT),
548
+ "Nothing here can tell whether some of it was delivered. Read `character op=\"state\"` "
549
+ .. "to see where things actually are."
550
+ )
551
+ end
552
+
553
+ local result = answer :: { [string]: any }
554
+ if result.ok ~= true then
555
+ Dispatch.fail("INPUT_FAILED", tostring(result.reason or "the client refused the plan"))
556
+ end
557
+
558
+ return {
559
+ delivered = true,
560
+ steps = #plan,
561
+ player = player.Name,
562
+ performed = result.performed,
563
+ --[[
564
+ Named so the caller can tell why a click that was delivered still hit
565
+ nothing. `landed` carries the arithmetic; this says what to turn off.
566
+ ]]
567
+ emulation = Emulation.summary(),
568
+ -- Where the client read the last click, against where it was aimed. See
569
+ -- the note in RELAY_SOURCE for why this is measured and not computed.
570
+ landed = result.landed,
571
+ -- Steps that were delivered and still did nothing. A `text` step with no
572
+ -- TextBox to type into is the one that matters.
573
+ notes = result.notes,
574
+ }
575
+ end
576
+
577
+ function Input.register()
578
+ Dispatch.registerAll("input", {
579
+ send = Input.send,
580
+ })
581
+ end
582
+
583
+ return Input