@el4cteo/rbx-studio-mcp 0.1.6 → 0.2.7

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 +67 -5
  2. package/dist/bridge/api.js +29 -0
  3. package/dist/bridge/api.js.map +1 -1
  4. package/dist/bridge/remote.js +35 -0
  5. package/dist/bridge/remote.js.map +1 -1
  6. package/dist/bridge/rpc.js +81 -2
  7. package/dist/bridge/rpc.js.map +1 -1
  8. package/dist/bridge/server.js +60 -3
  9. package/dist/bridge/server.js.map +1 -1
  10. package/dist/index.js +86 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/lib/protocol.js +23 -0
  13. package/dist/lib/protocol.js.map +1 -1
  14. package/dist/tools/discover.js +7 -1
  15. package/dist/tools/discover.js.map +1 -1
  16. package/dist/tools/input.js +63 -1
  17. package/dist/tools/input.js.map +1 -1
  18. package/dist/tools/instances.js +9 -1
  19. package/dist/tools/instances.js.map +1 -1
  20. package/dist/tools/perf.js +73 -16
  21. package/dist/tools/perf.js.map +1 -1
  22. package/dist/tools/playtest.js +8 -1
  23. package/dist/tools/playtest.js.map +1 -1
  24. package/dist/tools/session.js +32 -6
  25. package/dist/tools/session.js.map +1 -1
  26. package/dist/tools/world.js +9 -3
  27. package/dist/tools/world.js.map +1 -1
  28. package/package.json +2 -2
  29. package/plugin/src/Config.luau +1 -1
  30. package/plugin/src/Console.luau +204 -4
  31. package/plugin/src/Net.luau +23 -1
  32. package/plugin/src/Phrase.luau +158 -5
  33. package/plugin/src/Transport.luau +84 -19
  34. package/plugin/src/Visuals.luau +217 -20
  35. package/plugin/src/handlers/Debug.luau +63 -6
  36. package/plugin/src/handlers/Geometry.luau +20 -1
  37. package/plugin/src/handlers/Input.luau +41 -1
  38. package/plugin/src/handlers/Perf.luau +662 -645
  39. package/plugin/src/handlers/World.luau +19 -0
  40. package/plugin/src/init.server.luau +48 -11
  41. package/scripts/check-plugin.mjs +29 -1
  42. package/scripts/test-bridge.mjs +104 -0
  43. package/scripts/test-transport.mjs +58 -0
@@ -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
@@ -84,6 +84,35 @@ local overflow = 0
84
84
  local installed = false
85
85
  local installError: string? = nil
86
86
 
87
+ --[[
88
+ Which lines currently carry a breakpoint, per script.
89
+
90
+ `ScriptDebuggerService` exposes no way to list what is set -- `AddBreakpoint`
91
+ and `RemoveBreakpoint` are the whole surface, one line at a time -- so
92
+ "clear every breakpoint in this script" needs its own record of what this
93
+ module put there, or it cannot be done at all without either the caller
94
+ naming every line back or this file falling back to `ClearBreakpoints()`,
95
+ which takes the whole session's breakpoints with it, including ones set by
96
+ someone else sharing this Studio.
97
+ ]]
98
+ local trackedBreakpoints: { [Instance]: { [number]: boolean } } = {}
99
+
100
+ local function track(target: Instance, line: number)
101
+ local lines = trackedBreakpoints[target]
102
+ if lines == nil then
103
+ lines = {}
104
+ trackedBreakpoints[target] = lines
105
+ end
106
+ lines[line] = true
107
+ end
108
+
109
+ local function untrack(target: Instance, line: number)
110
+ local lines = trackedBreakpoints[target]
111
+ if lines then
112
+ lines[line] = nil
113
+ end
114
+ end
115
+
87
116
  --[[
88
117
  Flattens whatever the service hands back.
89
118
 
@@ -309,6 +338,7 @@ function Debug.set(params: { [string]: any }): { [string]: any }
309
338
  end)
310
339
 
311
340
  if ok then
341
+ track(target, descriptor.Line)
312
342
  table.insert(added, {
313
343
  path = Paths.of(target),
314
344
  line = descriptor.Line,
@@ -362,13 +392,39 @@ function Debug.clear(params: { [string]: any }): { [string]: any }
362
392
  if typeof(path) == "string" and path ~= "" then
363
393
  local target = Paths.resolve(path)
364
394
  local line = tonumber(params.line)
365
- if line == nil then
366
- Dispatch.fail("BAD_PARAMS", "removing one breakpoint needs a `line`.")
395
+
396
+ if line ~= nil then
397
+ local ok, removed = pcall(function()
398
+ return service:RemoveBreakpoint(target, line)
399
+ end)
400
+ untrack(target, line)
401
+ return { removed = ok and removed == true, path = Paths.of(target), line = line }
367
402
  end
368
- local ok, removed = pcall(function()
369
- return service:RemoveBreakpoint(target, line)
370
- end)
371
- return { removed = ok and removed == true, path = Paths.of(target), line = line }
403
+
404
+ --[[
405
+ `path` alone, no `line`: every breakpoint this module put in that
406
+ script. `ScriptDebuggerService` has no "list breakpoints in this
407
+ script" of its own -- only `AddBreakpoint`/`RemoveBreakpoint`, one
408
+ line at a time -- so `trackedBreakpoints` is what makes this
409
+ possible at all, and it is also the reason it can only remove what
410
+ this session set: a breakpoint another client or the user placed
411
+ by hand was never tracked here, and this cannot see it to touch it.
412
+ ]]
413
+ local lines = trackedBreakpoints[target]
414
+ local removedLines: { number } = {}
415
+ if lines then
416
+ for lineNumber in lines do
417
+ local ok = pcall(function()
418
+ service:RemoveBreakpoint(target, lineNumber)
419
+ end)
420
+ if ok then
421
+ table.insert(removedLines, lineNumber)
422
+ end
423
+ end
424
+ trackedBreakpoints[target] = nil
425
+ end
426
+ table.sort(removedLines)
427
+ return { removed = #removedLines > 0, path = Paths.of(target), lines = removedLines }
372
428
  end
373
429
 
374
430
  local ok, err = pcall(function()
@@ -377,6 +433,7 @@ function Debug.clear(params: { [string]: any }): { [string]: any }
377
433
  if not ok then
378
434
  Dispatch.fail("REFUSED", string.format("ClearBreakpoints refused: %s", tostring(err)))
379
435
  end
436
+ table.clear(trackedBreakpoints)
380
437
  return { cleared = true }
381
438
  end
382
439
 
@@ -138,7 +138,6 @@ function Geometry.combine(params: { [string]: any }): { [string]: any }
138
138
  then (if #produced == 1 then params.name else string.format("%s%d", params.name, index))
139
139
  else subject.Name
140
140
  part.Parent = parent
141
- table.insert(created, Paths.of(part))
142
141
  end
143
142
 
144
143
  -- Removing the inputs is opt-in, and only once the results exist: an
@@ -150,6 +149,26 @@ function Geometry.combine(params: { [string]: any }): { [string]: any }
150
149
  part:Destroy()
151
150
  end
152
151
  end
152
+
153
+ --[[
154
+ Paths for the produced parts are read only now, after the originals
155
+ are gone rather than in the loop above that named and parented them.
156
+
157
+ A result keeps the subject's name by default, so for the entire
158
+ window before the destroy above runs, the new part and the subject
159
+ it was built from are two same-named siblings in the same parent --
160
+ and Paths.of correctly, but uselessly, disambiguates that with a
161
+ `[2]` suffix that stops being true the instant the destroy runs a
162
+ few lines later. The caller reads this path after the call returns,
163
+ by which point the disambiguation was already wrong. Reading it here
164
+ instead describes where the part actually is once nothing further
165
+ in this function is going to move or remove anything -- correct
166
+ whether or not `keepOriginals` left a real, permanent collision for
167
+ it to still be disambiguating.
168
+ ]]
169
+ for _, part in produced do
170
+ table.insert(created, Paths.of(part))
171
+ end
153
172
  end)
154
173
 
155
174
  return { created = created, removed = removed, undoable = undoable }
@@ -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