@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.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 (75) hide show
  1. package/README.md +28 -2
  2. package/dist/bridge/console.js +182 -0
  3. package/dist/bridge/console.js.map +1 -1
  4. package/dist/index.js +8 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/cloudassets.js +233 -0
  7. package/dist/lib/cloudassets.js.map +1 -0
  8. package/dist/lib/credentials.js +180 -0
  9. package/dist/lib/credentials.js.map +1 -0
  10. package/dist/lib/livedata.js +325 -0
  11. package/dist/lib/livedata.js.map +1 -0
  12. package/dist/lib/liveluau.js +83 -0
  13. package/dist/lib/liveluau.js.map +1 -0
  14. package/dist/lib/liveops.js +358 -0
  15. package/dist/lib/liveops.js.map +1 -0
  16. package/dist/lib/opencloud.js +235 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/anim.js +159 -0
  19. package/dist/tools/anim.js.map +1 -0
  20. package/dist/tools/audio.js +96 -0
  21. package/dist/tools/audio.js.map +1 -0
  22. package/dist/tools/character.js +95 -5
  23. package/dist/tools/character.js.map +1 -1
  24. package/dist/tools/data.js +292 -0
  25. package/dist/tools/data.js.map +1 -0
  26. package/dist/tools/device.js +77 -7
  27. package/dist/tools/device.js.map +1 -1
  28. package/dist/tools/discover.js +80 -4
  29. package/dist/tools/discover.js.map +1 -1
  30. package/dist/tools/exec.js +96 -2
  31. package/dist/tools/exec.js.map +1 -1
  32. package/dist/tools/input.js +35 -9
  33. package/dist/tools/input.js.map +1 -1
  34. package/dist/tools/perf.js +74 -7
  35. package/dist/tools/perf.js.map +1 -1
  36. package/dist/tools/scripts.js +162 -6
  37. package/dist/tools/scripts.js.map +1 -1
  38. package/dist/tools/spatial.js +135 -0
  39. package/dist/tools/spatial.js.map +1 -0
  40. package/dist/tools/universe.js +177 -0
  41. package/dist/tools/universe.js.map +1 -0
  42. package/dist/tools/upload.js +294 -0
  43. package/dist/tools/upload.js.map +1 -0
  44. package/dist/tools/world.js +675 -51
  45. package/dist/tools/world.js.map +1 -1
  46. package/package.json +2 -2
  47. package/plugin/src/Commands.luau +31 -7
  48. package/plugin/src/Config.luau +65 -65
  49. package/plugin/src/Console.luau +1909 -1843
  50. package/plugin/src/Emulation.luau +172 -0
  51. package/plugin/src/Phrase.luau +816 -618
  52. package/plugin/src/Png.luau +8 -4
  53. package/plugin/src/Prompt.luau +965 -961
  54. package/plugin/src/Secret.luau +86 -0
  55. package/plugin/src/Serialize.luau +440 -8
  56. package/plugin/src/Undo.luau +94 -6
  57. package/plugin/src/handlers/Anim.luau +897 -0
  58. package/plugin/src/handlers/Assets.luau +286 -2
  59. package/plugin/src/handlers/Audio.luau +411 -0
  60. package/plugin/src/handlers/Capture.luau +155 -20
  61. package/plugin/src/handlers/Character.luau +823 -361
  62. package/plugin/src/handlers/Data.luau +539 -0
  63. package/plugin/src/handlers/Device.luau +394 -139
  64. package/plugin/src/handlers/Discover.luau +685 -363
  65. package/plugin/src/handlers/Geometry.luau +722 -450
  66. package/plugin/src/handlers/Instances.luau +84 -4
  67. package/plugin/src/handlers/Perf.luau +227 -0
  68. package/plugin/src/handlers/Scripts.luau +673 -539
  69. package/plugin/src/handlers/Session.luau +3 -0
  70. package/plugin/src/handlers/Spatial.luau +334 -0
  71. package/plugin/src/handlers/Viewport.luau +268 -0
  72. package/plugin/src/handlers/World.luau +89 -15
  73. package/plugin/src/init.server.luau +9 -1
  74. package/scripts/build-plugin.mjs +20 -0
  75. package/scripts/check-plugin.mjs +171 -124
@@ -146,6 +146,9 @@ function Session.status(): { [string]: any }
146
146
  scriptCount = totals.scripts,
147
147
  studioVersion = studioVersion(),
148
148
  emulatedDevice = Emulation.summary(),
149
+ -- Separate from the device: traffic can be shaped with no device set,
150
+ -- and that is the case most likely to be forgotten about.
151
+ emulatedNetwork = Emulation.networkSummary(),
149
152
  }
150
153
  end
151
154
 
@@ -0,0 +1,334 @@
1
+ --!strict
2
+ --[[
3
+ Spatial queries: casts, and "what is in this box".
4
+
5
+ These answer the one question the Explorer cannot: what is actually THERE.
6
+ A path tells you an instance exists and where its own pivot sits; it does not
7
+ tell you that the door frame overlaps the wall, that the spawn is buried a
8
+ stud inside the floor, or that nothing at all stands between the turret and
9
+ the player. Every one of those is a query against the world, and before this
10
+ the only way to run one was `execute_luau`.
11
+
12
+ All of it goes through a WorldRoot rather than through `workspace` directly,
13
+ for the same reason collision groups do: a `WorldModel` inside a ViewportFrame
14
+ is its own world with its own parts and its own collision groups, and a query
15
+ hard-coded to the Workspace can only ever lie about it.
16
+
17
+ The `collisionGroup` field is the point of doing this now. Roblox moved
18
+ collision group management onto WorldRoot in September 2026, and spatial
19
+ queries inside a WorldModel now honour groups the way Workspace always has.
20
+ A cast run in the wrong group reports a clear path through a wall the player
21
+ cannot walk through -- a wrong answer that looks exactly like a right one.
22
+ ]]
23
+
24
+ local Workspace = game:GetService("Workspace")
25
+
26
+ local Dispatch = require(script.Parent.Parent.Dispatch)
27
+ local Paths = require(script.Parent.Parent.Paths)
28
+ local Serialize = require(script.Parent.Parent.Serialize)
29
+
30
+ local Spatial = {}
31
+
32
+ -- A query that returns every part in a large place is not an answer, it is a
33
+ -- transcript. Callers narrow with `filter` or raise this deliberately.
34
+ local DEFAULT_LIMIT = 50
35
+ local MAX_LIMIT = 500
36
+
37
+ --[[
38
+ Which world the query runs in. Same rule as collision groups: the Workspace
39
+ unless a WorldModel is named, and anything that is not a WorldRoot is refused
40
+ by name rather than left to fail inside the engine call.
41
+ ]]
42
+ local function root(params: { [string]: any }): Instance
43
+ local path = params.worldModel
44
+ if typeof(path) ~= "string" or path == "" then
45
+ return Workspace
46
+ end
47
+
48
+ local instance = Paths.resolve(path)
49
+ if not instance:IsA("WorldRoot") then
50
+ Dispatch.fail(
51
+ "BAD_PARAMS",
52
+ string.format("%s is a %s, not a WorldModel.", path, instance.ClassName),
53
+ "Spatial queries run on a WorldRoot: the Workspace, or a WorldModel "
54
+ .. "inside a ViewportFrame. Omit `worldModel` for the Workspace."
55
+ )
56
+ end
57
+ return instance
58
+ end
59
+
60
+ local function vector(value: any, field: string): Vector3
61
+ if typeof(value) == "table" then
62
+ local list = value :: { any }
63
+ local x, y, z = tonumber(list[1]), tonumber(list[2]), tonumber(list[3])
64
+ if x and y and z then
65
+ return Vector3.new(x, y, z)
66
+ end
67
+ end
68
+ if typeof(value) == "string" then
69
+ local ok, parsed = Serialize.parse(value, "Vector3")
70
+ if ok and typeof(parsed) == "Vector3" then
71
+ return parsed
72
+ end
73
+ end
74
+ Dispatch.fail(
75
+ "BAD_PARAMS",
76
+ string.format("`%s` is not a position.", field),
77
+ 'Write it the way the Properties panel does: "12, 0, 5".'
78
+ )
79
+ error("unreachable")
80
+ end
81
+
82
+ local function optionalVector(value: any, field: string): Vector3?
83
+ if value == nil or value == "" then
84
+ return nil
85
+ end
86
+ return vector(value, field)
87
+ end
88
+
89
+ --[[
90
+ Builds the filter every query shares.
91
+
92
+ `ignore` and `only` are the same engine field with the list flipped, because
93
+ "everything except the character" and "only the doors" are both common and
94
+ only one of them is expressible at a time. Naming a path that no longer
95
+ exists is a caller mistake worth reporting: silently querying against an
96
+ empty filter gives a plausible answer to the wrong question.
97
+ ]]
98
+ local function buildParams(params: { [string]: any }): RaycastParams
99
+ local query = RaycastParams.new()
100
+
101
+ local list = params.only or params.ignore
102
+ if list ~= nil then
103
+ if typeof(list) ~= "table" then
104
+ Dispatch.fail("BAD_PARAMS", "`ignore` and `only` take a list of paths.")
105
+ end
106
+ local resolved, missing = Paths.resolveMany(list :: { string })
107
+ if #missing > 0 then
108
+ Dispatch.fail(
109
+ "NOT_FOUND",
110
+ string.format("Could not resolve: %s", table.concat(missing, ", "))
111
+ )
112
+ end
113
+ query.FilterDescendantsInstances = resolved
114
+ query.FilterType = if params.only ~= nil
115
+ then Enum.RaycastFilterType.Include
116
+ else Enum.RaycastFilterType.Exclude
117
+ end
118
+
119
+ if typeof(params.collisionGroup) == "string" and params.collisionGroup ~= "" then
120
+ query.CollisionGroup = params.collisionGroup
121
+ end
122
+ -- Off by default, matching the engine. A caller asking "can the player see
123
+ -- the exit" usually does want water ignored; one asking "what is here" does
124
+ -- not, and neither can be guessed.
125
+ query.RespectCanCollide = params.respectCanCollide == true
126
+ query.IgnoreWater = params.ignoreWater == true
127
+ query.BruteForceAllSlow = false
128
+ return query
129
+ end
130
+
131
+ local function describeHit(result: RaycastResult, origin: Vector3): { [string]: any }
132
+ return {
133
+ path = Paths.of(result.Instance),
134
+ class = result.Instance.ClassName,
135
+ position = Serialize.value(result.Position),
136
+ normal = Serialize.value(result.Normal),
137
+ material = tostring(result.Material):gsub("Enum%.Material%.", ""),
138
+ distance = math.round((result.Position - origin).Magnitude * 100) / 100,
139
+ }
140
+ end
141
+
142
+ local function describePart(part: BasePart): { [string]: any }
143
+ return {
144
+ path = Paths.of(part),
145
+ class = part.ClassName,
146
+ position = Serialize.value(part.Position),
147
+ size = Serialize.value(part.Size),
148
+ collisionGroup = part.CollisionGroup,
149
+ canCollide = part.CanCollide,
150
+ }
151
+ end
152
+
153
+ --[[
154
+ Casts a ray, a block or a sphere.
155
+
156
+ The three are one operation with one extra argument, and the engine treats
157
+ them that way too -- `Blockcast` and `Spherecast` take the same params and
158
+ return the same RaycastResult. Splitting them into three handlers would only
159
+ mean three copies of the filter code.
160
+
161
+ A miss is reported as `hit = false` with the distance travelled, not as an
162
+ error and not as an empty object. "Nothing is in the way" is a real answer
163
+ and usually the one being checked for.
164
+ ]]
165
+ function Spatial.cast(params: { [string]: any }): { [string]: any }
166
+ local world = root(params) :: WorldRoot
167
+ local shape = tostring(params.shape or "ray")
168
+
169
+ local origin = vector(params.from, "from")
170
+
171
+ --[[
172
+ Direction is given either as a target point or as a vector, because both
173
+ are natural and they are not interchangeable. "Can the turret see the
174
+ player" is two positions; "is there ground below" is a direction and a
175
+ distance. Taking only one of them forces the caller to do vector maths
176
+ in the prompt, which is exactly where an off-by-one sign lives.
177
+ ]]
178
+ local direction: Vector3
179
+ local to = optionalVector(params.to, "to")
180
+ if to then
181
+ direction = to - origin
182
+ else
183
+ local towards = optionalVector(params.direction, "direction")
184
+ if not towards then
185
+ Dispatch.fail(
186
+ "BAD_PARAMS",
187
+ "A cast needs somewhere to go.",
188
+ "Give `to` for a point to aim at, or `direction` plus `distance`."
189
+ )
190
+ error("unreachable")
191
+ end
192
+ local distance = tonumber(params.distance) or 100
193
+ direction = towards.Unit * distance
194
+ end
195
+
196
+ local query = buildParams(params)
197
+ local result: RaycastResult?
198
+
199
+ if shape == "ray" then
200
+ result = world:Raycast(origin, direction, query)
201
+ elseif shape == "block" then
202
+ local size = optionalVector(params.size, "size") or Vector3.new(1, 1, 1)
203
+ result = world:Blockcast(CFrame.new(origin), size, direction, query)
204
+ elseif shape == "sphere" then
205
+ local radius = tonumber(params.radius) or 1
206
+ result = world:Spherecast(origin, radius, direction, query)
207
+ else
208
+ Dispatch.fail(
209
+ "BAD_PARAMS",
210
+ string.format("unknown cast shape %q", shape),
211
+ 'Use "ray", "block" or "sphere".'
212
+ )
213
+ end
214
+
215
+ if not result then
216
+ return {
217
+ hit = false,
218
+ shape = shape,
219
+ from = Serialize.value(origin),
220
+ travelled = math.round(direction.Magnitude * 100) / 100,
221
+ world = Paths.of(world),
222
+ note = "Nothing was in the way for the whole length of the cast.",
223
+ }
224
+ end
225
+
226
+ local hit = describeHit(result, origin)
227
+ hit.hit = true
228
+ hit.shape = shape
229
+ hit.from = Serialize.value(origin)
230
+ hit.world = Paths.of(world)
231
+ return hit
232
+ end
233
+
234
+ --[[
235
+ Everything inside a volume.
236
+
237
+ `part` is the one worth reaching for on a build: it takes an existing part
238
+ and reports what overlaps it, which answers "is this door clipping the wall"
239
+ directly rather than by reconstructing the door's bounds by hand.
240
+ ]]
241
+ function Spatial.overlap(params: { [string]: any }): { [string]: any }
242
+ local world = root(params) :: WorldRoot
243
+ local region = tostring(params.region or "box")
244
+ local limit = math.clamp(tonumber(params.limit) or DEFAULT_LIMIT, 1, MAX_LIMIT)
245
+
246
+ local overlap = OverlapParams.new()
247
+ local query = buildParams(params)
248
+ overlap.FilterDescendantsInstances = query.FilterDescendantsInstances
249
+ overlap.FilterType = query.FilterType
250
+ overlap.CollisionGroup = query.CollisionGroup
251
+ overlap.RespectCanCollide = query.RespectCanCollide
252
+ -- Asked for one over the limit so the response can say whether the list was
253
+ -- cut, instead of a caller reading exactly 50 as "there are 50".
254
+ overlap.MaxParts = limit + 1
255
+
256
+ local found: { BasePart }
257
+ local about: { [string]: any } = {}
258
+
259
+ if region == "box" then
260
+ local centre = vector(params.at, "at")
261
+ local size = optionalVector(params.size, "size") or Vector3.new(4, 4, 4)
262
+ found = world:GetPartBoundsInBox(CFrame.new(centre), size, overlap)
263
+ about.at, about.size = Serialize.value(centre), Serialize.value(size)
264
+ elseif region == "radius" then
265
+ local centre = vector(params.at, "at")
266
+ local radius = tonumber(params.radius) or 4
267
+ found = world:GetPartBoundsInRadius(centre, radius, overlap)
268
+ about.at, about.radius = Serialize.value(centre), radius
269
+ elseif region == "part" then
270
+ local path = params.path
271
+ if typeof(path) ~= "string" or path == "" then
272
+ Dispatch.fail("BAD_PARAMS", 'region="part" needs `path`, the part to test.')
273
+ end
274
+ local instance = Paths.resolve(path)
275
+ if not instance:IsA("BasePart") then
276
+ Dispatch.fail(
277
+ "BAD_PARAMS",
278
+ string.format("%s is a %s, not a part.", path, instance.ClassName)
279
+ )
280
+ end
281
+ local subject = instance :: BasePart
282
+ --[[
283
+ The subject always overlaps itself, and a caller who asked what
284
+ touches a door does not mean the door. Added to the filter rather
285
+ than stripped from the results, so it does not eat one of the slots
286
+ the limit allows.
287
+ ]]
288
+ if overlap.FilterType == Enum.RaycastFilterType.Exclude then
289
+ local excluded = overlap.FilterDescendantsInstances
290
+ table.insert(excluded, subject)
291
+ overlap.FilterDescendantsInstances = excluded
292
+ end
293
+ found = world:GetPartsInPart(subject, overlap)
294
+ about.path = Paths.of(subject)
295
+ else
296
+ Dispatch.fail(
297
+ "BAD_PARAMS",
298
+ string.format("unknown region %q", region),
299
+ 'Use "box", "radius" or "part".'
300
+ )
301
+ error("unreachable")
302
+ end
303
+
304
+ local truncated = #found > limit
305
+ local items: { { [string]: any } } = {}
306
+ for index, part in found do
307
+ if index > limit then
308
+ break
309
+ end
310
+ table.insert(items, describePart(part))
311
+ end
312
+
313
+ about.region = region
314
+ about.items = items
315
+ about.count = #items
316
+ about.truncated = truncated
317
+ about.world = Paths.of(world)
318
+ if truncated then
319
+ about.note = string.format(
320
+ "More than %d parts are in this volume; raise `limit` or narrow with `only`.",
321
+ limit
322
+ )
323
+ end
324
+ return about
325
+ end
326
+
327
+ function Spatial.register()
328
+ Dispatch.registerAll("spatial", {
329
+ cast = Spatial.cast,
330
+ overlap = Spatial.overlap,
331
+ })
332
+ end
333
+
334
+ return Spatial
@@ -15,10 +15,12 @@
15
15
 
16
16
  local RunService = game:GetService("RunService")
17
17
  local Selection = game:GetService("Selection")
18
+ local StarterGui = game:GetService("StarterGui")
18
19
  local TextService = game:GetService("TextService")
19
20
  local Workspace = game:GetService("Workspace")
20
21
 
21
22
  local Dispatch = require(script.Parent.Parent.Dispatch)
23
+ local Emulation = require(script.Parent.Parent.Emulation)
22
24
  local Paths = require(script.Parent.Parent.Paths)
23
25
  local Serialize = require(script.Parent.Parent.Serialize)
24
26
 
@@ -403,8 +405,274 @@ function Viewport.textbounds(params: { [string]: any }): { [string]: any }
403
405
  return report
404
406
  end
405
407
 
408
+ --[[
409
+ Every interface fault that is invisible in the data model.
410
+
411
+ `textbounds` already answers "does this text fit its label". This is the same
412
+ question asked of the whole screen, and it exists because the data model
413
+ cannot answer it at all: a button positioned off the side of a phone has a
414
+ perfectly correct `Position`, a `Size` that is not zero, and a parent that is
415
+ visible. Nothing about the instance is wrong. It is simply not where anyone
416
+ can reach it, and the only ways to find that out today are to play the game
417
+ or to look at a picture.
418
+
419
+ Studio lays StarterGui out for real in edit mode -- measured:
420
+ `AbsolutePosition` and `AbsoluteSize` carry live values with nothing running,
421
+ and they move when a device is emulated. So the audit runs against whatever
422
+ `device` is currently emulating, which is what makes it worth running twice:
423
+ once on desktop, once on a phone.
424
+
425
+ It reports rather than judges. Every finding names the element and the
426
+ number, so a caller can decide whether a label four pixels off the edge is a
427
+ bug or a deliberate bleed.
428
+ ]]
429
+
430
+ --[[
431
+ Text below this many pixels tall is not readable on a phone.
432
+
433
+ Roblox's own UI guidance and every accessibility standard land in the same
434
+ place: somewhere around 12 device-independent pixels is the floor for body
435
+ text. 10 is used here because it is the point past which there is no
436
+ argument -- a label under it is unreadable on any display, not merely small.
437
+ ]]
438
+ local MIN_TEXT_PIXELS = 10
439
+
440
+ --[[
441
+ Pairs compared for overlap before the check gives up.
442
+
443
+ Overlap is quadratic in the number of siblings, and a scrolling list of two
444
+ hundred rows is both a legitimate design and forty thousand comparisons of
445
+ rectangles that are supposed to sit next to each other. Past this the check
446
+ reports that it stopped rather than continuing to spend the user's frame on
447
+ an answer nobody asked for.
448
+ ]]
449
+ local MAX_OVERLAP_PAIRS = 2000
450
+
451
+ local function rectOf(element: GuiObject): (number, number, number, number)
452
+ local position = element.AbsolutePosition
453
+ local size = element.AbsoluteSize
454
+ return position.X, position.Y, position.X + size.X, position.Y + size.Y
455
+ end
456
+
457
+ local function overlaps(a: GuiObject, b: GuiObject): boolean
458
+ local ax1, ay1, ax2, ay2 = rectOf(a)
459
+ local bx1, by1, bx2, by2 = rectOf(b)
460
+ return ax1 < bx2 and bx1 < ax2 and ay1 < by2 and by1 < ay2
461
+ end
462
+
463
+ --[[
464
+ Whether this element is actually on screen at all.
465
+
466
+ Walks up rather than trusting `Visible`: an element with Visible true inside
467
+ a frame with Visible false is not on screen, and reporting it as a fault
468
+ would bury the real findings under every hidden menu in the place.
469
+ ]]
470
+ local function shown(element: Instance): boolean
471
+ local node: Instance? = element
472
+ while node ~= nil and not node:IsA("LayerCollector") do
473
+ if node:IsA("GuiObject") and not (node :: GuiObject).Visible then
474
+ return false
475
+ end
476
+ node = node.Parent
477
+ end
478
+ if node ~= nil and node:IsA("ScreenGui") then
479
+ return (node :: ScreenGui).Enabled
480
+ end
481
+ return node ~= nil
482
+ end
483
+
484
+ function Viewport.ui(params: { [string]: any }): { [string]: any }
485
+ local root: Instance = if typeof(params.path) == "string" and params.path ~= ""
486
+ then Paths.resolve(params.path)
487
+ else StarterGui
488
+
489
+ local camera = Workspace.CurrentCamera
490
+ if camera == nil then
491
+ Dispatch.fail("NO_CAMERA", "This session has no camera, so there is no screen to measure against.")
492
+ end
493
+ local screen = (camera :: Camera).ViewportSize
494
+ local screenWidth, screenHeight = math.floor(screen.X), math.floor(screen.Y)
495
+
496
+ local elements: { GuiObject } = {}
497
+ for _, descendant in root:GetDescendants() do
498
+ if descendant:IsA("GuiObject") then
499
+ table.insert(elements, descendant :: GuiObject)
500
+ end
501
+ end
502
+
503
+ local findings: { { [string]: any } } = {}
504
+ local checked = 0
505
+ local hidden = 0
506
+
507
+ local function report(element: GuiObject, issue: string, detail: string)
508
+ if #findings >= 100 then
509
+ return
510
+ end
511
+ table.insert(findings, {
512
+ path = Paths.of(element),
513
+ name = element.Name,
514
+ className = element.ClassName,
515
+ issue = issue,
516
+ detail = detail,
517
+ })
518
+ end
519
+
520
+ local visible: { GuiObject } = {}
521
+
522
+ for _, element in elements do
523
+ if not shown(element) then
524
+ hidden += 1
525
+ continue
526
+ end
527
+ checked += 1
528
+ table.insert(visible, element)
529
+
530
+ local size = element.AbsoluteSize
531
+ local position = element.AbsolutePosition
532
+
533
+ if size.X <= 0 or size.Y <= 0 then
534
+ report(element, "zero size", string.format("%.0f x %.0f -- nothing is drawn", size.X, size.Y))
535
+ continue
536
+ end
537
+
538
+ --[[
539
+ Off screen, and by how much.
540
+
541
+ Reported as a measurement rather than a yes/no, because the two cases
542
+ it covers are different bugs: an element wholly outside the screen was
543
+ positioned for a display that is not this one, and one clipped at an
544
+ edge is usually a layout that does not fit. The numbers tell them
545
+ apart; a boolean would not.
546
+ ]]
547
+ local right = position.X + size.X
548
+ local bottom = position.Y + size.Y
549
+ if right <= 0 or bottom <= 0 or position.X >= screenWidth or position.Y >= screenHeight then
550
+ report(
551
+ element,
552
+ "off screen",
553
+ string.format(
554
+ "at %.0f,%.0f on a %dx%d screen -- entirely outside it",
555
+ position.X,
556
+ position.Y,
557
+ screenWidth,
558
+ screenHeight
559
+ )
560
+ )
561
+ elseif position.X < 0 or position.Y < 0 or right > screenWidth or bottom > screenHeight then
562
+ report(
563
+ element,
564
+ "clipped",
565
+ string.format(
566
+ "%.0f,%.0f to %.0f,%.0f runs past the %dx%d screen",
567
+ position.X,
568
+ position.Y,
569
+ right,
570
+ bottom,
571
+ screenWidth,
572
+ screenHeight
573
+ )
574
+ )
575
+ end
576
+
577
+ if element:IsA("TextLabel") or element:IsA("TextButton") or element:IsA("TextBox") then
578
+ local text = element :: any
579
+ --[[
580
+ The size the text is actually drawn at, which is not TextSize when
581
+ TextScaled is on: the engine then fits the string to the box and
582
+ `TextBounds` is the only honest report of the result.
583
+ ]]
584
+ local drawn = if text.TextScaled then text.TextBounds.Y else text.TextSize
585
+ if drawn > 0 and drawn < MIN_TEXT_PIXELS and text.Text ~= "" then
586
+ report(
587
+ element,
588
+ "text too small",
589
+ string.format("%.0fpx -- under %dpx is unreadable on a phone", drawn, MIN_TEXT_PIXELS)
590
+ )
591
+ end
592
+ if text.TextTruncate == Enum.TextTruncate.None and not text.TextScaled and not text.TextWrapped then
593
+ if text.TextBounds.X > size.X + 1 then
594
+ report(
595
+ element,
596
+ "text overflows",
597
+ string.format("needs %.0fpx, has %.0fpx", text.TextBounds.X, size.X)
598
+ )
599
+ end
600
+ end
601
+ end
602
+ end
603
+
604
+ --[[
605
+ Overlap between siblings only.
606
+
607
+ A child sitting on top of its parent is the normal way UI is built, and
608
+ reporting it would make the check useless. Two buttons in the same frame
609
+ covering each other is the fault worth naming.
610
+ ]]
611
+ --[[
612
+ Whether an element is something a user reads or presses.
613
+
614
+ Overlap between decoration is not a fault, it is how UI is built: a drop
615
+ shadow sits under a panel, a bevel sits under a label, a playhead sits
616
+ over a timeline. Measured on a real HUD, the first version of this check
617
+ reported four overlaps and all four were of that kind -- a Frame named
618
+ Shadow behind the three things it was drawn to sit behind.
619
+
620
+ Two pieces of TEXT on top of each other, or two images, is the fault
621
+ worth naming. So a plain coloured Frame is not counted; something
622
+ carrying text or an image is.
623
+ ]]
624
+ local function carriesContent(element: GuiObject): boolean
625
+ if element:IsA("TextLabel") or element:IsA("TextButton") or element:IsA("TextBox") then
626
+ return (element :: any).Text ~= ""
627
+ end
628
+ if element:IsA("ImageLabel") or element:IsA("ImageButton") then
629
+ return (element :: any).Image ~= ""
630
+ end
631
+ return false
632
+ end
633
+
634
+ local pairs = 0
635
+ local stopped = false
636
+ for index = 1, #visible do
637
+ for other = index + 1, #visible do
638
+ if pairs >= MAX_OVERLAP_PAIRS then
639
+ stopped = true
640
+ break
641
+ end
642
+ local a, b = visible[index], visible[other]
643
+ if a.Parent == b.Parent then
644
+ pairs += 1
645
+ if
646
+ overlaps(a, b)
647
+ and a.AbsoluteSize.X > 0
648
+ and b.AbsoluteSize.X > 0
649
+ and carriesContent(a)
650
+ and carriesContent(b)
651
+ then
652
+ report(a, "overlaps", string.format("covers or is covered by %s", b.Name))
653
+ end
654
+ end
655
+ end
656
+ if stopped then
657
+ break
658
+ end
659
+ end
660
+
661
+ return {
662
+ screen = string.format("%dx%d", screenWidth, screenHeight),
663
+ device = Emulation.deviceId(),
664
+ root = Paths.of(root),
665
+ checked = checked,
666
+ hidden = hidden,
667
+ findings = findings,
668
+ findingCount = #findings,
669
+ overlapStopped = stopped,
670
+ }
671
+ end
672
+
406
673
  function Viewport.register()
407
674
  Dispatch.registerAll("viewport", {
675
+ ui = Viewport.ui,
408
676
  textbounds = Viewport.textbounds,
409
677
  raycast = Viewport.raycast,
410
678
  select = Viewport.select,