@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.
- package/README.md +20 -26
- package/dist/index.js +1 -1
- package/dist/lib/format.js +18 -2
- package/dist/lib/format.js.map +1 -1
- package/dist/tools/api.js +20 -1
- package/dist/tools/api.js.map +1 -1
- package/dist/tools/debug.js +18 -8
- package/dist/tools/debug.js.map +1 -1
- package/dist/tools/discover.js +5 -0
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/input.js +38 -5
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/instances.js +49 -2
- package/dist/tools/instances.js.map +1 -1
- package/dist/tools/perf.js +9 -8
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/scripts.js +10 -1
- package/dist/tools/scripts.js.map +1 -1
- package/package.json +1 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Console.luau +301 -117
- package/plugin/src/Mirror.luau +126 -0
- package/plugin/src/Serialize.luau +43 -3
- package/plugin/src/ThemePicker.luau +458 -0
- package/plugin/src/Themes/Aurora.luau +161 -0
- package/plugin/src/Themes/Blueprint.luau +213 -0
- package/plugin/src/Themes/Draw.luau +177 -0
- package/plugin/src/Themes/Lattice.luau +304 -0
- package/plugin/src/Themes/Nebula.luau +182 -0
- package/plugin/src/Themes/Observatory.luau +200 -0
- package/plugin/src/Themes/Orbit.luau +268 -0
- package/plugin/src/Themes/Phosphor.luau +200 -0
- package/plugin/src/Themes/Theme.luau +121 -0
- package/plugin/src/Themes/Void.luau +266 -0
- package/plugin/src/Themes/init.luau +116 -0
- package/plugin/src/Undo.luau +29 -0
- package/plugin/src/Visuals.luau +838 -907
- package/plugin/src/handlers/Device.luau +21 -1
- package/plugin/src/handlers/Discover.luau +75 -1
- package/plugin/src/handlers/Exec.luau +17 -5
- package/plugin/src/handlers/Input.luau +583 -493
- package/plugin/src/handlers/Instances.luau +35 -3
- 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
|
-
|
|
61
|
-
local
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
local
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
the
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
--
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
local
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
local
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
"
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
local
|
|
424
|
-
if
|
|
425
|
-
Dispatch.fail("
|
|
426
|
-
end
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
"
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
end
|
|
492
|
-
|
|
493
|
-
|
|
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
|