@el4cteo/rbx-studio-mcp 0.2.0 → 0.2.8

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.
@@ -28,6 +28,19 @@ export type Status = "disconnected" | "connecting" | "connected"
28
28
  type Callbacks = {
29
29
  onCommand: (id: string, op: string, params: { [string]: any }?) -> (),
30
30
  onStatus: (status: Status, detail: string?) -> (),
31
+ --[[
32
+ Anything the bridge wants to tell this plugin that is not a command.
33
+
34
+ The stream was one-way in practice -- commands down, results back over a
35
+ separate POST -- and there was no way for the server to say something the
36
+ plugin should merely know, like how many agents are now sharing it. A
37
+ frame carrying `event` instead of `id`/`op` is that channel.
38
+
39
+ No protocol bump: a plugin older than this drops any frame without an id
40
+ and an op, which is exactly the right behaviour for a frame it does not
41
+ understand.
42
+ ]]
43
+ onEvent: (event: { [string]: any }) -> (),
31
44
  }
32
45
 
33
46
  local running = false
@@ -87,23 +100,53 @@ local function identity(transport: string): { [string]: any }
87
100
  end
88
101
 
89
102
  --[[
90
- Parses one SSE payload. `MessageReceived` hands over the event data, but the
91
- exact framing is not contractual, so a stray `data:` prefix is tolerated and
92
- comment/keepalive lines are dropped.
103
+ Parses one delivery from the stream, which may hold more than one event.
104
+
105
+ This used to assume one event per `MessageReceived` and decode the whole
106
+ payload as a single JSON object. That held only because the bridge had never
107
+ written two frames in the same tick -- and the moment it did, both were lost:
108
+ the two writes arrived in one chunk, so the decode saw
109
+ `{...}<blank>data: {...}` and returned nil for the pair. The visible symptom
110
+ was a client-count badge that went up and never came back down. The invisible
111
+ one is why this is written properly rather than patched around: nothing stops
112
+ a *command* from sharing a chunk with anything else, and a dropped command is
113
+ a tool call that disappears with no error on either side.
114
+
115
+ So the payload is split into events on blank lines, and each event's `data:`
116
+ lines are concatenated the way the SSE spec says they should be. Comments --
117
+ the `: connected` greeting and the `: ping` keepalives -- are skipped rather
118
+ than being allowed to poison the block they arrive with.
93
119
  ]]
94
- local function parseFrame(message: string): { [string]: any }?
95
- local text = (string.gsub(message, "^%s+", ""))
96
- if text == "" or string.sub(text, 1, 1) == ":" then
97
- return nil
98
- end
99
- if string.sub(text, 1, 5) == "data:" then
100
- text = (string.gsub(string.sub(text, 6), "^%s+", ""))
101
- end
102
- local decoded = Net.decode(text)
103
- if typeof(decoded) ~= "table" then
104
- return nil
120
+ local function parseFrames(message: string): { { [string]: any } }
121
+ local frames: { { [string]: any } } = {}
122
+
123
+ -- Normalised so one split handles either line ending.
124
+ local normalised = (string.gsub(message, "\r\n", "\n"))
125
+
126
+ -- The appended separator gives every event, the last one included, a
127
+ -- trailing blank line, so a single pattern finds them all.
128
+ for block in string.gmatch(normalised .. "\n\n", "(.-)\n\n") do
129
+ local payload: string? = nil
130
+ for line in string.gmatch(block, "[^\n]+") do
131
+ if string.sub(line, 1, 1) ~= ":" then
132
+ local value = line
133
+ if string.sub(value, 1, 5) == "data:" then
134
+ value = (string.gsub(string.sub(value, 6), "^%s+", ""))
135
+ end
136
+ -- Several data lines in one event concatenate, per the spec.
137
+ payload = if payload == nil then value else payload .. "\n" .. value
138
+ end
139
+ end
140
+
141
+ if payload ~= nil and payload ~= "" then
142
+ local decoded = Net.decode(payload)
143
+ if typeof(decoded) == "table" then
144
+ table.insert(frames, decoded)
145
+ end
146
+ end
105
147
  end
106
- return decoded
148
+
149
+ return frames
107
150
  end
108
151
 
109
152
  local function handleFrame(callbacks: Callbacks, frame: { [string]: any })
@@ -111,6 +154,10 @@ local function handleFrame(callbacks: Callbacks, frame: { [string]: any })
111
154
  local op = frame.op
112
155
  if typeof(id) == "string" and typeof(op) == "string" then
113
156
  callbacks.onCommand(id, op, frame.params)
157
+ return
158
+ end
159
+ if typeof(frame.event) == "string" then
160
+ callbacks.onEvent(frame)
114
161
  end
115
162
  end
116
163
 
@@ -151,8 +198,7 @@ local function runStream(callbacks: Callbacks): boolean
151
198
  table.insert(
152
199
  connections,
153
200
  client.MessageReceived:Connect(function(message: string)
154
- local frame = parseFrame(message)
155
- if frame then
201
+ for _, frame in parseFrames(message) do
156
202
  handleFrame(callbacks, frame)
157
203
  end
158
204
  end)
@@ -224,8 +270,21 @@ local function runPolling(callbacks: Callbacks): boolean
224
270
  return false
225
271
  else
226
272
  local payload = Net.decode(response.body)
227
- if typeof(payload) == "table" and typeof(payload.command) == "table" then
228
- handleFrame(callbacks, payload.command)
273
+ if typeof(payload) == "table" then
274
+ if typeof(payload.command) == "table" then
275
+ handleFrame(callbacks, payload.command)
276
+ end
277
+ --[[
278
+ Poll sessions get the same news, riding on the answer they were
279
+ already waiting for. The bridge has nowhere to push to here, so
280
+ every poll response carries the current value rather than only
281
+ the moments it changed -- the console filters out the repeats,
282
+ and a poll session that missed a change while it was handling a
283
+ command would otherwise never hear about it.
284
+ ]]
285
+ if typeof(payload.clients) == "number" then
286
+ callbacks.onEvent({ event = "clients", count = payload.clients })
287
+ end
229
288
  end
230
289
  end
231
290
 
@@ -123,10 +123,36 @@ local INNER_SCALE = 0.42
123
123
  -- The solid was originally as tall as the band and read as looming: it crowded
124
124
  -- the caption and made a 46px strip feel like a panel. It is an indicator, not
125
125
  -- the subject, so it sits small with air around it.
126
- Visuals.BAND_HEIGHT = 44
127
- local CELL = 34
126
+ --
127
+ -- Both grew when the solid learned to overshoot. `cell` clips its descendants,
128
+ -- so a shape that swells to 1.4x has to have somewhere to swell into, and the
129
+ -- extra band height goes to the trace, whose bars are what people try to point
130
+ -- at.
131
+ Visuals.BAND_HEIGHT = 52
132
+ local CELL = 40
128
133
  local GUTTER = 16
129
134
 
135
+ --[[
136
+ The solid's resting radius as a fraction of the cell.
137
+
138
+ Tuned against the new cell so resting size is unchanged: 40 x 0.185 is 7.4px
139
+ where 34 x 0.2 was 6.8px. At full overshoot the perspective divide pushes a
140
+ near vertex to roughly 15px, which still clears the cell's 20px half-width.
141
+ ]]
142
+ local BASE_RADIUS = 0.185
143
+
144
+ --[[
145
+ Spring constants for the scale. Stiff enough that a call reads as a snap
146
+ rather than a swell, damped just under critical so a reply overshoots once
147
+ and settles instead of ringing.
148
+ ]]
149
+ local SPRING = 90
150
+ local DAMPING = 14
151
+
152
+ -- How long the session must go without a command before the solid winds down.
153
+ -- Matches the console's idle notice, so the picture and the log agree.
154
+ local QUIET_AFTER = 20
155
+
130
156
  -- How many calls the trace remembers. Forty is about a screen's width of bars
131
157
  -- at two pixels each, and about as far back as anyone reads a trend.
132
158
  local TRACE = 40
@@ -141,6 +167,10 @@ local SLOW_MS = 400
141
167
  -- rather than falling into a dead gap.
142
168
  local BAR_STRIDE = 5
143
169
 
170
+ -- Height of the trace, and so of every bar's hit column. The taller band spends
171
+ -- its extra pixels here: a 19px column was a small thing to aim a pointer at.
172
+ local TRACE_HEIGHT = 26
173
+
144
174
  type Bar = {
145
175
  frame: Frame,
146
176
  --[[
@@ -153,6 +183,15 @@ type Bar = {
153
183
  hit: TextButton,
154
184
  milliseconds: number,
155
185
  ok: boolean,
186
+ --[[
187
+ What ran, in the same words the log uses.
188
+
189
+ The trace plotted durations with nothing to attach them to, so a tall bar
190
+ raised the question it could not answer: forty numbers and no way to tell
191
+ which of them was the script edit. Carried per bar rather than looked up,
192
+ because the bar outlives whatever produced it.
193
+ ]]
194
+ title: string,
156
195
  }
157
196
 
158
197
  type Runtime = {
@@ -174,6 +213,20 @@ type Runtime = {
174
213
 
175
214
  energy: number,
176
215
  shake: number,
216
+ --[[
217
+ The solid's size, as a spring rather than a value.
218
+
219
+ Scale was previously a small function of energy -- a 24% range that
220
+ nobody could see. Driving it as a spring means each event can push it and
221
+ let physics do the rest: a dispatch pulls it in, a reply throws it out
222
+ past its resting size, and it settles on its own.
223
+ ]]
224
+ scale: number,
225
+ scaleVelocity: number,
226
+ -- Brightens and fattens the vertices for a moment after a call lands.
227
+ flare: number,
228
+ -- Seconds since anything last ran. Drives the wind-down.
229
+ quiet: number,
177
230
  tint: Color3,
178
231
  targetTint: Color3,
179
232
  baseTint: Color3,
@@ -200,6 +253,10 @@ local runtime: Runtime = {
200
253
  shapeIndex = 1,
201
254
  energy = 0,
202
255
  shake = 0,
256
+ scale = 1,
257
+ scaleVelocity = 0,
258
+ flare = 0,
259
+ quiet = 0,
203
260
  tint = Color3.fromRGB(167, 139, 250),
204
261
  targetTint = Color3.fromRGB(167, 139, 250),
205
262
  baseTint = Color3.fromRGB(167, 139, 250),
@@ -251,7 +308,16 @@ local function step(delta: number)
251
308
 
252
309
  runtime.energy = math.max(0, runtime.energy - delta * 0.9)
253
310
  runtime.shake = math.max(0, runtime.shake - delta * 2.4)
254
- runtime.activity = math.max(0, runtime.activity - delta * 0.35)
311
+ --[[
312
+ Slower than it was. The shape is meant to climb as the session works, and
313
+ at the old rates a call had to arrive every three seconds just to hold the
314
+ octahedron -- so the icosahedron at the top of the list was, in practice,
315
+ unreachable. A burst of four calls now gets there, and it unwinds over
316
+ about ten seconds afterwards.
317
+ ]]
318
+ runtime.activity = math.max(0, runtime.activity - delta * 0.28)
319
+ runtime.flare = math.max(0, runtime.flare - delta * 3)
320
+ runtime.quiet += delta
255
321
  --[[
256
322
  Two colours, blended by how recently something happened.
257
323
 
@@ -268,14 +334,52 @@ local function step(delta: number)
268
334
 
269
335
  -- Turning faster while busy makes the rate itself readable: a glance says
270
336
  -- whether anything is happening without reading a word.
271
- runtime.angle += runtime.spin * delta * (1 + runtime.energy * 2.2)
337
+ --
338
+ -- And nearly stopping once the session has gone quiet, for the same reason in
339
+ -- reverse: a strip that keeps spinning at its working rate hours after the
340
+ -- last command claims an activity that is not happening.
341
+ local pace = if runtime.quiet > QUIET_AFTER then 0.25 else 1
342
+ runtime.angle += runtime.spin * delta * (1 + runtime.energy * 2.2) * pace
343
+
344
+ --[[
345
+ Where the solid wants to be, before the spring gets a say.
346
+
347
+ Three things move it and they are deliberately ordered. Energy -- how
348
+ recently work happened -- swells it while a session is busy. The breathe
349
+ is a slow sine that only survives while energy is near zero, so an idle
350
+ panel is visibly alive without a resting animation competing with the
351
+ reaction to a real call. And past QUIET_AFTER seconds of nothing, it draws
352
+ in and stays there: the picture of a session that has stopped.
353
+ ]]
354
+ local calm = 1 - math.clamp(runtime.energy, 0, 1)
355
+ local breathe = math.sin(os.clock() * 0.9) * 0.04 * calm
356
+ local target = 1 + math.clamp(runtime.energy, 0, 1.6) * 0.18 + breathe
357
+ if runtime.quiet > QUIET_AFTER then
358
+ target = 0.82
359
+ end
360
+
361
+ --[[
362
+ A damped spring rather than a lerp, because a lerp cannot overshoot and
363
+ overshoot is the whole point: a reply that pushes the solid past its
364
+ resting size and lets it fall back reads as a thing being struck, where
365
+ easing toward a value reads as a slider being dragged.
366
+
367
+ Integrated semi-implicitly -- velocity first, then position from the new
368
+ velocity -- which stays stable at the frame times Studio actually hands us
369
+ rather than only at small ones.
370
+ ]]
371
+ local step = math.min(delta, 1 / 30)
372
+ runtime.scaleVelocity += (target - runtime.scale) * SPRING * step
373
+ runtime.scaleVelocity -= runtime.scaleVelocity * DAMPING * step
374
+ runtime.scale += runtime.scaleVelocity * step
375
+ runtime.scale = math.clamp(runtime.scale, 0.55, 1.7)
272
376
 
273
377
  local shape = SHAPES[runtime.shapeIndex]
274
378
  local centre = Vector2.new(CELL / 2, CELL / 2)
275
379
  -- Kept clear of the cell's edge. The perspective divide pushes a near vertex
276
380
  -- outward, so sizing to the full half-width clipped the solid against its own
277
381
  -- container whenever a corner swung toward the viewer.
278
- local radius = CELL * (0.2 + runtime.energy * 0.03)
382
+ local radius = CELL * BASE_RADIUS * runtime.scale
279
383
 
280
384
  -- Wobble the axis rather than the panel: shaking the strip would read as a
281
385
  -- glitch, tilting the solid reads as it being knocked.
@@ -309,9 +413,9 @@ local function step(delta: number)
309
413
  frame,
310
414
  projected[a],
311
415
  projected[b],
312
- math.max(1, math.floor(1 + depth * 1.6)),
313
- runtime.tint:Lerp(Color3.new(1, 1, 1), depth * 0.35),
314
- 0.84 - depth * 0.74
416
+ math.max(1, math.floor(1 + depth * 1.6 + runtime.flare * 0.8)),
417
+ runtime.tint:Lerp(Color3.new(1, 1, 1), depth * 0.35 + runtime.flare * 0.3),
418
+ math.max(0, 0.84 - depth * 0.74 - runtime.flare * 0.3)
315
419
  )
316
420
  end
317
421
 
@@ -330,12 +434,15 @@ local function step(delta: number)
330
434
  continue
331
435
  end
332
436
  local depth = math.clamp((depths[index] + 2) / 4, 0, 1)
333
- local size = math.max(2, math.floor(1.5 + depth * 2.5))
437
+ -- The corners carry the flare. A pulse of light travelling through the
438
+ -- vertices is what makes a completed call land as an event rather than as
439
+ -- a bar quietly appearing somewhere off to the right.
440
+ local size = math.max(2, math.floor(1.5 + depth * 2.5 + runtime.flare * 2.5))
334
441
  dot.Visible = true
335
442
  dot.Position = UDim2.fromOffset(point.X, point.Y)
336
443
  dot.Size = UDim2.fromOffset(size, size)
337
444
  dot.BackgroundColor3 = runtime.tint:Lerp(Color3.new(1, 1, 1), 0.25 + depth * 0.5)
338
- dot.BackgroundTransparency = 0.55 - depth * 0.5
445
+ dot.BackgroundTransparency = math.max(0, 0.55 - depth * 0.5 - runtime.flare * 0.45)
339
446
  end
340
447
 
341
448
  --[[
@@ -392,12 +499,29 @@ local function showReading(entry: Bar)
392
499
  return
393
500
  end
394
501
 
395
- highlight.Position = UDim2.new(entry.hit.Position.X.Scale, entry.hit.Position.X.Offset, 0, 0)
502
+ --[[
503
+ The Y here has to be 1, and was 0.
504
+
505
+ The highlight is anchored to its own bottom edge, so a Y scale of 0 put
506
+ that edge on the trace's top line and drew the whole 26px column above it
507
+ -- outside a frame that clips its descendants. The hover has therefore
508
+ never been visible to anyone: the hit targets fired, the readout appeared,
509
+ and the highlight they were meant to explain was off screen every time.
510
+ ]]
511
+ highlight.Position = UDim2.new(entry.hit.Position.X.Scale, entry.hit.Position.X.Offset, 1, 0)
396
512
  highlight.Visible = true
397
513
 
514
+ --[[
515
+ Named, not just timed.
516
+
517
+ A duration with nothing attached to it raises the question it cannot
518
+ answer -- forty bars, one of them tall, and no way to tell whether that
519
+ was a script edit or a screenshot. The title is the same phrase the log
520
+ row uses, so pointing at a bar and reading the log agree with each other.
521
+ ]]
398
522
  readout.Text = if entry.ok
399
- then string.format("%d ms", math.round(entry.milliseconds))
400
- else string.format("failed after %d ms", math.round(entry.milliseconds))
523
+ then string.format("%s: %d ms", entry.title, math.round(entry.milliseconds))
524
+ else string.format("%s: failed after %d ms", entry.title, math.round(entry.milliseconds))
401
525
  readout.TextColor3 = if entry.ok
402
526
  then runtime.palette.text or Color3.fromRGB(226, 232, 240)
403
527
  else runtime.palette.red or Color3.fromRGB(251, 113, 133)
@@ -420,11 +544,26 @@ end
420
544
  distinguishable at a glance -- which is the pair of questions that actually
421
545
  get asked of a log this size.
422
546
  ]]
423
- function Visuals.recordCall(milliseconds: number, ok: boolean)
547
+ function Visuals.recordCall(milliseconds: number, ok: boolean, title: string)
424
548
  runtime.energy = math.min(1.6, runtime.energy + (if ok then 0.3 else 0.8))
425
- runtime.activity = math.min(1, runtime.activity + 0.25)
549
+ runtime.activity = math.min(1, runtime.activity + 0.34)
426
550
  runtime.shapeIndex = math.clamp(1 + math.floor(runtime.activity * (#SHAPES - 0.001)), 1, #SHAPES)
427
- if not ok then
551
+ runtime.quiet = 0
552
+ runtime.flare = 1
553
+
554
+ --[[
555
+ A shove rather than a new target.
556
+
557
+ Setting the scale outright would snap; giving the spring velocity lets it
558
+ carry past its resting size and fall back, which is the difference between
559
+ a value changing and something being struck. A failure shoves the other
560
+ way and knocks the axis with it.
561
+ ]]
562
+ if ok then
563
+ runtime.scaleVelocity += 9
564
+ else
565
+ runtime.scale = 0.8
566
+ runtime.scaleVelocity = -2
428
567
  runtime.shake = 1
429
568
  end
430
569
 
@@ -449,7 +588,13 @@ function Visuals.recordCall(milliseconds: number, ok: boolean)
449
588
  hit.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
450
589
  hit.Parent = trace
451
590
 
452
- local entry: Bar = { frame = bar, hit = hit, milliseconds = milliseconds, ok = ok }
591
+ local entry: Bar = {
592
+ frame = bar,
593
+ hit = hit,
594
+ milliseconds = milliseconds,
595
+ ok = ok,
596
+ title = if title ~= "" then title else "call",
597
+ }
453
598
  table.insert(runtime.bars, entry)
454
599
 
455
600
  hit.MouseEnter:Connect(function()
@@ -507,6 +652,15 @@ end
507
652
  function Visuals.setKind(color: Color3, urgency: number)
508
653
  runtime.activeTint = color
509
654
  runtime.energy = math.min(1.6, math.max(runtime.energy, urgency))
655
+ runtime.quiet = 0
656
+ --[[
657
+ Drawn in as the command goes out, so the pair reads as one gesture: the
658
+ solid contracts while the request is in flight and springs open when the
659
+ answer arrives. On a fast call the two are almost one motion, which is
660
+ itself the report -- a slow call visibly holds its breath.
661
+ ]]
662
+ runtime.scale = math.min(runtime.scale, 0.85)
663
+ runtime.scaleVelocity = math.min(runtime.scaleVelocity, 0)
510
664
  -- Re-aimed per kind so successive commands of different types visibly
511
665
  -- change the axis rather than continuing the same turn.
512
666
  runtime.spin = Vector3.new(
@@ -543,6 +697,37 @@ function Visuals.setIdle()
543
697
  else "waiting for a command"
544
698
  end
545
699
 
700
+ --[[
701
+ Winds the solid down, because the session has stopped rather than paused.
702
+
703
+ The strip already decays on its own, but decay bottoms out at "idle and
704
+ turning", which looks the same after twenty seconds as after two hours. This
705
+ is the console telling the picture what it has just told the log, so the two
706
+ agree: the shape falls back to a tetrahedron, the spin drops to a quarter
707
+ pace, and the solid draws in.
708
+ ]]
709
+ function Visuals.setQuiet()
710
+ runtime.quiet = QUIET_AFTER + 1
711
+ runtime.activity = 0
712
+ runtime.shapeIndex = 1
713
+ end
714
+
715
+ --[[
716
+ Empties the trace.
717
+
718
+ Paired with the console's clear button. The bars used to survive it while the
719
+ footer counters reset, so "clear" wiped the log, wiped the statistics, and
720
+ left forty timings from the session it had just erased sitting on screen.
721
+ ]]
722
+ function Visuals.clearTrace()
723
+ for _, entry in runtime.bars do
724
+ entry.frame:Destroy()
725
+ entry.hit:Destroy()
726
+ end
727
+ table.clear(runtime.bars)
728
+ clearReading()
729
+ end
730
+
546
731
  function Visuals.setVisible(visible: boolean)
547
732
  local band = runtime.band
548
733
  if band == nil then
@@ -649,7 +834,7 @@ function Visuals.mount(parent: Instance, palette: { [string]: Color3 })
649
834
  local trace = Instance.new("Frame")
650
835
  trace.Name = "Trace"
651
836
  trace.Position = UDim2.new(0, CELL + GUTTER, 0, 21)
652
- trace.Size = UDim2.new(1, -CELL - GUTTER - 12, 0, 19)
837
+ trace.Size = UDim2.new(1, -CELL - GUTTER - 12, 0, TRACE_HEIGHT)
653
838
  trace.BackgroundTransparency = 1
654
839
  trace.ClipsDescendants = true
655
840
  trace.Parent = band
@@ -677,7 +862,10 @@ function Visuals.mount(parent: Instance, palette: { [string]: Color3 })
677
862
  highlight.Position = UDim2.new(1, 0, 1, 0)
678
863
  highlight.Size = UDim2.new(0, BAR_STRIDE, 1, 0)
679
864
  highlight.BackgroundColor3 = palette.text or Color3.fromRGB(226, 232, 240)
680
- highlight.BackgroundTransparency = 0.85
865
+ -- Faint enough not to hide the bar it sits behind, solid enough to be seen
866
+ -- at all. The previous value was chosen for a highlight that never rendered,
867
+ -- so it had never been looked at.
868
+ highlight.BackgroundTransparency = 0.7
681
869
  highlight.BorderSizePixel = 0
682
870
  highlight.Visible = false
683
871
  highlight.Parent = trace
@@ -693,7 +881,11 @@ function Visuals.mount(parent: Instance, palette: { [string]: Color3 })
693
881
  readout.Name = "Readout"
694
882
  readout.AnchorPoint = Vector2.new(0, 0.5)
695
883
  readout.Position = UDim2.new(0, 0, 0.5, 0)
696
- readout.Size = UDim2.new(0, 110, 0, 14)
884
+ -- Sized by its text now that it carries a name as well as a number. A fixed
885
+ -- 110px was enough for "18 ms" and truncates anything with a phrase in front
886
+ -- of it, which is the half worth reading.
887
+ readout.AutomaticSize = Enum.AutomaticSize.X
888
+ readout.Size = UDim2.new(0, 0, 0, 14)
697
889
  readout.BackgroundColor3 = palette.background or Color3.fromRGB(11, 12, 20)
698
890
  readout.BackgroundTransparency = 0.15
699
891
  readout.BorderSizePixel = 0
@@ -705,6 +897,11 @@ function Visuals.mount(parent: Instance, palette: { [string]: Color3 })
705
897
  readout.ZIndex = 3
706
898
  readout.Parent = trace
707
899
  runtime.readout = readout
900
+
901
+ local readoutPadding = Instance.new("UIPadding")
902
+ readoutPadding.PaddingLeft = UDim.new(0, 4)
903
+ readoutPadding.PaddingRight = UDim.new(0, 4)
904
+ readoutPadding.Parent = readout
708
905
  end
709
906
 
710
907
  return Visuals
@@ -29,6 +29,7 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
29
29
  local RunService = game:GetService("RunService")
30
30
 
31
31
  local Dispatch = require(script.Parent.Parent.Dispatch)
32
+ local Emulation = require(script.Parent.Parent.Emulation)
32
33
 
33
34
  local Input = {}
34
35
 
@@ -73,6 +74,36 @@ end
73
74
  local virtual = UserInputService:CreateVirtualInput()
74
75
  local performed = {}
75
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
+
76
107
  --[[
77
108
  The on-screen pointer.
78
109
 
@@ -240,6 +271,7 @@ for _, step in plan do
240
271
  elseif kind == "click" then
241
272
  local button = Enum.UserInputType[step.button or "MouseButton1"]
242
273
  local at = Vector2.new(step.x, step.y)
274
+ aimed = { x = step.x, y = step.y }
243
275
  -- Travel first, then click. Teleporting the pointer onto the target at
244
276
  -- the same instant it fires looks like nothing happened at all.
245
277
  moveCursor(step.x, step.y, 0.3)
@@ -265,7 +297,7 @@ if cursorGui ~= nil then
265
297
  cursorGui:Destroy()
266
298
  end
267
299
 
268
- report:FireServer({ ok = true, performed = performed })
300
+ report:FireServer({ ok = true, performed = performed, landed = landed })
269
301
  ]==]
270
302
 
271
303
  local function playerFor(name: string?): Player
@@ -441,6 +473,14 @@ function Input.send(params: { [string]: any }): { [string]: any }
441
473
  steps = #plan,
442
474
  player = player.Name,
443
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,
444
484
  }
445
485
  end
446
486