@el4cteo/rbx-studio-mcp 0.4.2 → 0.4.5

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 (38) hide show
  1. package/README.md +211 -211
  2. package/dist/bridge/api.js +3 -0
  3. package/dist/bridge/api.js.map +1 -1
  4. package/dist/bridge/failover.js +13 -0
  5. package/dist/bridge/failover.js.map +1 -1
  6. package/dist/bridge/remote.js +15 -1
  7. package/dist/bridge/remote.js.map +1 -1
  8. package/dist/bridge/rpc.js +35 -6
  9. package/dist/bridge/rpc.js.map +1 -1
  10. package/dist/bridge/server.js +55 -7
  11. package/dist/bridge/server.js.map +1 -1
  12. package/dist/doctor.js +160 -0
  13. package/dist/doctor.js.map +1 -0
  14. package/dist/index.js +29 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/lib/protocol.js.map +1 -1
  17. package/dist/tools/exec.js +53 -3
  18. package/dist/tools/exec.js.map +1 -1
  19. package/dist/tools/generate.js +120 -0
  20. package/dist/tools/generate.js.map +1 -0
  21. package/dist/tools/scripts.js +20 -1
  22. package/dist/tools/scripts.js.map +1 -1
  23. package/dist/tools/world.js +203 -20
  24. package/dist/tools/world.js.map +1 -1
  25. package/package.json +2 -1
  26. package/plugin/src/Config.luau +1 -1
  27. package/plugin/src/Console.luau +233 -9
  28. package/plugin/src/Transport.luau +6 -1
  29. package/plugin/src/Visuals.luau +52 -1
  30. package/plugin/src/handlers/Assets.luau +352 -145
  31. package/plugin/src/handlers/Generate.luau +386 -0
  32. package/plugin/src/handlers/Geometry.luau +170 -0
  33. package/plugin/src/handlers/Scripts.luau +79 -1
  34. package/plugin/src/handlers/Viewport.luau +416 -302
  35. package/plugin/src/handlers/World.luau +15 -5
  36. package/plugin/src/init.server.luau +725 -674
  37. package/scripts/test-bridge.mjs +56 -0
  38. package/scripts/test-failover.mjs +136 -95
@@ -0,0 +1,386 @@
1
+ --!strict
2
+ --[[
3
+ 3D generation: prompt to model, and cutting a mesh into named parts.
4
+
5
+ `GenerationService` is Roblox's Cube model. `GenerateModelAsync` is the one
6
+ to use -- `GenerateMeshAsync`, the older single-mesh call, is marked for
7
+ removal and is not exposed here.
8
+
9
+ Two things about it are easy to get wrong from the outside:
10
+
11
+ 1. The **schema** is not decoration. It decides how the result is split. A
12
+ `Body1` generation is one MeshPart and cannot be articulated; `Car5`
13
+ returns a body and four wheels under known names, which is what makes a
14
+ retargetable driving script able to find the wheels. Asking for "a car"
15
+ under `Body1` gives a car-shaped rock.
16
+ 2. It is **slow and metered**. Tens of seconds is normal, and the service
17
+ rejects prompts on moderation and rate limits its own throughput. Those
18
+ arrive as thrown strings, so every call here is wrapped and the reason is
19
+ handed back as a hint rather than a stack trace.
20
+
21
+ Generation happens *before* the undo recording opens. The call yields for a
22
+ long time and a ChangeHistoryService recording held open across it would sit
23
+ over the user's Studio for the whole wait; nothing is in the data model
24
+ until the model is parented, so there is nothing to protect until then.
25
+ ]]
26
+
27
+ local Dispatch = require(script.Parent.Parent.Dispatch)
28
+ local Paths = require(script.Parent.Parent.Paths)
29
+ local Undo = require(script.Parent.Parent.Undo)
30
+
31
+ local Generate = {}
32
+
33
+ -- Names the engine accepts for `PredefinedSchema`, lowercased for matching.
34
+ local PREDEFINED: { [string]: string } = {
35
+ car5 = "Car5",
36
+ body1 = "Body1",
37
+ }
38
+
39
+ --[[
40
+ Turns whatever the service threw into something an agent can act on.
41
+
42
+ The service reports content and quota problems as plain strings, and they are
43
+ the two failures a caller can actually do something about -- reword, or wait.
44
+ Everything else is passed through unchanged rather than guessed at.
45
+ ]]
46
+ local function hintFor(reason: string): string?
47
+ local lower = string.lower(reason)
48
+ if string.find(lower, "moderat") then
49
+ return "The prompt was rejected by moderation. Rewrite it and try again."
50
+ elseif string.find(lower, "rate limit") or string.find(lower, "overload") then
51
+ return "The service is rate limited or busy. Wait a moment and retry."
52
+ elseif string.find(lower, "character limit") then
53
+ return "The prompt is too long. Shorten it."
54
+ elseif string.find(lower, "timeout") or string.find(lower, "timed out") then
55
+ return "Generation took longer than the service allows. Try a simpler prompt."
56
+ end
57
+ return nil
58
+ end
59
+
60
+ --[[
61
+ Appearance carried from a source MeshPart onto the pieces cut out of it.
62
+
63
+ `TextureContent` is the one that matters and the reason this list exists:
64
+ segmentation returns bare geometry, so a textured chest came back as a
65
+ flat grey chest -- the right shape, visibly wrong, and the sort of thing a
66
+ caller then has to notice and repair by hand. The mesh's UVs survive the
67
+ cut, so re-pointing each piece at the original texture maps correctly.
68
+ ]]
69
+ local CARRIED = {
70
+ "TextureContent", "Color", "Material", "MaterialVariant", "Reflectance",
71
+ "Transparency", "CastShadow", "DoubleSided", "CanCollide", "CanTouch",
72
+ "CanQuery", "CollisionGroup",
73
+ }
74
+
75
+ --[[
76
+ Guarded per property: the pieces and the source are both MeshParts today,
77
+ but a property one of them loses in a future engine version should cost that
78
+ property rather than the whole segmentation.
79
+ ]]
80
+ local function carryLook(source: MeshPart, model: Model)
81
+ for _, descendant in model:GetDescendants() do
82
+ if descendant:IsA("MeshPart") then
83
+ for _, property in CARRIED do
84
+ pcall(function()
85
+ (descendant :: any)[property] = (source :: any)[property]
86
+ end)
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ local function parseVector(text: any): Vector3?
93
+ if typeof(text) ~= "string" or text == "" then
94
+ return nil
95
+ end
96
+ local x, y, z = string.match(text, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
97
+ if x == nil then
98
+ return nil
99
+ end
100
+ return Vector3.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
101
+ end
102
+
103
+ local function service(): any
104
+ local ok, found = pcall(function()
105
+ return game:GetService("GenerationService")
106
+ end)
107
+ if not ok or found == nil then
108
+ Dispatch.fail(
109
+ "NO_GENERATION",
110
+ "This Studio build has no GenerationService.",
111
+ "3D generation needs a current Studio. Update Studio and retry."
112
+ )
113
+ end
114
+ return found
115
+ end
116
+
117
+ --[[
118
+ Builds the `schema` argument, which takes exactly one key.
119
+
120
+ `groups` wins when both are given, because naming parts is the more specific
121
+ request: a caller who lists group names has a structure in mind that no
122
+ predefined schema is going to match.
123
+ ]]
124
+ local function buildSchema(params: { [string]: any }): ({ [string]: any }, string)
125
+ local groups = params.groups
126
+ if typeof(groups) == "table" and #(groups :: { any }) > 0 then
127
+ local names: { string } = {}
128
+ for _, name in groups :: { any } do
129
+ if typeof(name) == "string" and name ~= "" then
130
+ table.insert(names, name)
131
+ end
132
+ end
133
+ if #names == 0 then
134
+ Dispatch.fail("BAD_PARAMS", "`groups` was given but held no usable names.")
135
+ end
136
+ return { SchemaDefinition = { Groups = names } }, string.format("%d custom group(s)", #names)
137
+ end
138
+
139
+ local asked = string.lower(tostring(params.schema or "Body1"))
140
+ local predefined = PREDEFINED[asked]
141
+ if predefined == nil then
142
+ Dispatch.fail(
143
+ "BAD_PARAMS",
144
+ string.format("unknown schema %q", tostring(params.schema)),
145
+ 'Use "Body1", "Car5", or pass `groups` to name the parts yourself.'
146
+ )
147
+ end
148
+ return { PredefinedSchema = predefined }, predefined
149
+ end
150
+
151
+ --[[
152
+ Parents, names, scales and places a freshly generated model.
153
+
154
+ Scaling happens before the pivot so the pivot lands where the caller asked
155
+ rather than where the unscaled model's centre happened to be. Anchoring is
156
+ on by default: the generated parts arrive unanchored and a multi-part result
157
+ dropped into an edit-mode workspace otherwise sags apart the moment physics
158
+ steps.
159
+ ]]
160
+ local function place(model: Model, params: { [string]: any }): { [string]: any }
161
+ local parent = if typeof(params.parent) == "string" and params.parent ~= ""
162
+ then Paths.resolve(params.parent)
163
+ else workspace
164
+
165
+ local target = tonumber(params.scaleTo)
166
+ local position = parseVector(params.position)
167
+ local anchor = params.anchor ~= false
168
+
169
+ local path = ""
170
+ local parts: { string } = {}
171
+
172
+ local _, undoable = Undo.record("MCPGenerate", "MCP generate", function()
173
+ if typeof(params.name) == "string" and params.name ~= "" then
174
+ model.Name = params.name
175
+ end
176
+
177
+ if target and target > 0 then
178
+ local extents = model:GetExtentsSize()
179
+ local largest = math.max(extents.X, extents.Y, extents.Z)
180
+ if largest > 0 then
181
+ model:ScaleTo(target / largest)
182
+ end
183
+ end
184
+
185
+ for _, descendant in model:GetDescendants() do
186
+ if descendant:IsA("BasePart") then
187
+ if anchor then
188
+ (descendant :: BasePart).Anchored = true
189
+ end
190
+ if #parts < 40 then
191
+ table.insert(parts, descendant.Name)
192
+ end
193
+ end
194
+ end
195
+
196
+ model.Parent = parent
197
+
198
+ if position then
199
+ -- After parenting, so the pivot is settled.
200
+ pcall(function()
201
+ model:PivotTo(CFrame.new(position :: Vector3))
202
+ end)
203
+ end
204
+
205
+ path = Paths.of(model)
206
+ end)
207
+
208
+ local extents = model:GetExtentsSize()
209
+ return {
210
+ path = path,
211
+ parts = parts,
212
+ size = string.format("%.1f, %.1f, %.1f", extents.X, extents.Y, extents.Z),
213
+ anchored = anchor,
214
+ undoable = undoable,
215
+ }
216
+ end
217
+
218
+ --[[
219
+ Prompt (and/or reference image) to a model.
220
+ ]]
221
+ function Generate.model(params: { [string]: any }): { [string]: any }
222
+ local prompt = if typeof(params.prompt) == "string" then params.prompt else ""
223
+ local imageId = tonumber(params.imageAssetId)
224
+ if prompt == "" and (imageId == nil or imageId <= 0) then
225
+ Dispatch.fail("BAD_PARAMS", "generate needs a `prompt`, an `imageAssetId`, or both.")
226
+ end
227
+
228
+ local inputs: { [string]: any } = {}
229
+ if prompt ~= "" then
230
+ inputs.TextPrompt = prompt
231
+ end
232
+ if imageId and imageId > 0 then
233
+ local okImage, content = pcall(function()
234
+ return (Content :: any).fromAssetId(imageId)
235
+ end)
236
+ if not okImage then
237
+ Dispatch.fail(
238
+ "BAD_PARAMS",
239
+ string.format("Could not reference image asset %d.", imageId),
240
+ "Pass the numeric id of an image asset you own."
241
+ )
242
+ end
243
+ inputs.Image = content
244
+ end
245
+
246
+ local size = parseVector(params.size)
247
+ if size then
248
+ inputs.Size = size
249
+ end
250
+ local triangles = tonumber(params.maxTriangles)
251
+ if triangles and triangles > 0 then
252
+ inputs.MaxTriangles = math.floor(triangles)
253
+ end
254
+ if params.textures == false then
255
+ inputs.GenerateTextures = false
256
+ end
257
+
258
+ local schema, described = buildSchema(params)
259
+
260
+ local ok, result, metadata = pcall(function()
261
+ return service():GenerateModelAsync(inputs, schema)
262
+ end)
263
+ if not ok then
264
+ local reason = tostring(result)
265
+ Dispatch.fail("GENERATE_FAILED", string.format("Generation refused: %s", reason), hintFor(reason))
266
+ end
267
+ if typeof(result) ~= "Instance" or not (result :: Instance):IsA("Model") then
268
+ Dispatch.fail(
269
+ "GENERATE_FAILED",
270
+ string.format("Generation returned a %s, not a Model.", typeof(result)),
271
+ "Nothing was added to the place."
272
+ )
273
+ end
274
+
275
+ local report = place(result :: Model, params)
276
+ report.schema = described
277
+ report.prompt = if prompt ~= "" then prompt else nil
278
+ report.uuid = if typeof(metadata) == "table" then tostring((metadata :: any).UUID or "") else nil
279
+ return report
280
+ end
281
+
282
+ --[[
283
+ Cuts an existing MeshPart into the parts a schema names.
284
+
285
+ This is the half of the service that has nothing to do with prompting: it
286
+ takes geometry that already exists -- an imported mesh, or an earlier `Body1`
287
+ generation -- and splits it. That is how a single-mesh car becomes a car with
288
+ four wheels that can turn.
289
+
290
+ Studio only. The engine refuses it at runtime, and refuses a published mesh
291
+ the signed-in user has no edit permission for.
292
+ ]]
293
+ function Generate.segment(params: { [string]: any }): { [string]: any }
294
+ local path = tostring(params.path or "")
295
+ if path == "" then
296
+ Dispatch.fail("BAD_PARAMS", "segment needs a `path` to a MeshPart.")
297
+ end
298
+ local instance = Paths.resolve(path)
299
+ if not instance:IsA("MeshPart") then
300
+ Dispatch.fail(
301
+ "NOT_A_MESH",
302
+ string.format("%s is a %s, not a MeshPart.", path, instance.ClassName),
303
+ "Only MeshParts can be segmented. Generate one first, or import a mesh."
304
+ )
305
+ end
306
+
307
+ local schema, described = buildSchema(params)
308
+
309
+ local ok, result, metadata = pcall(function()
310
+ return service():SegmentMeshAsync(instance :: MeshPart, schema)
311
+ end)
312
+ if not ok then
313
+ local reason = tostring(result)
314
+ Dispatch.fail(
315
+ "SEGMENT_FAILED",
316
+ string.format("Segmentation refused: %s", reason),
317
+ hintFor(reason)
318
+ or "Segmenting needs edit permission on the mesh asset, and only works in Studio."
319
+ )
320
+ end
321
+ if typeof(result) ~= "Instance" or not (result :: Instance):IsA("Model") then
322
+ Dispatch.fail(
323
+ "SEGMENT_FAILED",
324
+ string.format("Segmentation returned a %s, not a Model.", typeof(result)),
325
+ "The original mesh is untouched."
326
+ )
327
+ end
328
+
329
+ local source = instance :: MeshPart
330
+ carryLook(source, result :: Model)
331
+
332
+ --[[
333
+ Land where the original stood, and default to its parent, so a segmented
334
+ mesh replaces the thing it came from instead of appearing at the origin.
335
+ Read before anything is parented or destroyed, because both change what
336
+ these answers would be.
337
+ ]]
338
+ if params.parent == nil and source.Parent then
339
+ params.parent = Paths.of(source.Parent)
340
+ end
341
+ if params.position == nil then
342
+ local at = source.Position
343
+ params.position = string.format("%f, %f, %f", at.X, at.Y, at.Z)
344
+ end
345
+ if params.name == nil then
346
+ params.name = source.Name .. "_Segmented"
347
+ end
348
+ if params.scaleTo == nil then
349
+ --[[
350
+ Segmentation hands back the mesh at its own native scale, not at the
351
+ size the MeshPart was displayed at. Measured: a crate shown as 6 x
352
+ 3.3 x 4.1 studs came back as 1.9 x 1.1 x 1.3 -- the same shape, a
353
+ third the size, sitting where the original was. Since the point of
354
+ this op is to replace a part with an articulated version of itself,
355
+ the size it was on screen is the size to keep.
356
+ ]]
357
+ local extents = source.Size
358
+ params.scaleTo = math.max(extents.X, extents.Y, extents.Z)
359
+ end
360
+
361
+ local report = place(result :: Model, params)
362
+ report.schema = described
363
+ report.uuid = if typeof(metadata) == "table" then tostring((metadata :: any).UUID or "") else nil
364
+
365
+ if params.keepOriginal ~= true then
366
+ local removed = Paths.of(source)
367
+ local _, dropped = Undo.record("MCPSegmentCleanup", "MCP segment cleanup", function()
368
+ source:Destroy()
369
+ end)
370
+ report.removed = removed
371
+ -- Two recordings, so two Ctrl+Z. Said plainly rather than claimed as one.
372
+ report.undoable = report.undoable == true and dropped
373
+ report.steps = 2
374
+ end
375
+
376
+ return report
377
+ end
378
+
379
+ function Generate.register()
380
+ Dispatch.registerAll("generate", {
381
+ model = Generate.model,
382
+ segment = Generate.segment,
383
+ })
384
+ end
385
+
386
+ return Generate
@@ -53,6 +53,17 @@ local function carryAppearance(source: BasePart, target: BasePart)
53
53
  end
54
54
  end
55
55
 
56
+ local function parseVector(text: any): Vector3?
57
+ if typeof(text) ~= "string" or text == "" then
58
+ return nil
59
+ end
60
+ local x, y, z = string.match(text, "^%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*,%s*(-?[%d%.]+)%s*$")
61
+ if x == nil then
62
+ return nil
63
+ end
64
+ return Vector3.new(tonumber(x) :: number, tonumber(y) :: number, tonumber(z) :: number)
65
+ end
66
+
56
67
  local function resolveParts(paths: any, what: string): { BasePart }
57
68
  if typeof(paths) ~= "table" or #(paths :: { any }) == 0 then
58
69
  Dispatch.fail("BAD_PARAMS", string.format("geometry needs %s.", what))
@@ -270,10 +281,169 @@ function Geometry.fragment(params: { [string]: any }): { [string]: any }
270
281
  return { created = created, pieces = #produced, undoable = undoable }
271
282
  end
272
283
 
284
+ --[[
285
+ Builds the motion to sweep along, as a list of CFrames.
286
+
287
+ Three ways to say it, in the order they are checked. `positions` is the
288
+ escape hatch; `to` is a slide; `spin` is a hinge, which is the case worth
289
+ having -- a door, a hatch, a drawbridge. All three are sampled rather than
290
+ solved, so `steps` trades accuracy for time: the volume is the hull of the
291
+ samples, and too few of them on a wide arc cuts the corners off the swing.
292
+ ]]
293
+ local function motionOf(subject: BasePart, params: { [string]: any }): { CFrame }
294
+ local start = subject.CFrame
295
+ local steps = math.clamp(tonumber(params.steps) or 12, 2, 64)
296
+
297
+ local positions = params.positions
298
+ if typeof(positions) == "table" and #(positions :: { any }) > 0 then
299
+ local frames: { CFrame } = {}
300
+ for _, entry in positions :: { any } do
301
+ local at = parseVector(entry)
302
+ if at == nil then
303
+ Dispatch.fail("BAD_PARAMS", string.format("%q is not an \"x, y, z\" position.", tostring(entry)))
304
+ end
305
+ table.insert(frames, start.Rotation + (at :: Vector3))
306
+ end
307
+ return frames
308
+ end
309
+
310
+ local spin = tonumber(params.spin)
311
+ if spin ~= nil and spin ~= 0 then
312
+ local axis = parseVector(params.axis) or Vector3.yAxis
313
+ if axis.Magnitude == 0 then
314
+ Dispatch.fail("BAD_PARAMS", "`axis` cannot be zero.")
315
+ end
316
+ -- The pivot is the hinge. Defaulting it to the part's own centre makes
317
+ -- the part spin in place, which is right for a wheel and wrong for a
318
+ -- door -- so a door has to name its hinge edge.
319
+ local pivot = parseVector(params.pivot) or subject.Position
320
+ local frames: { CFrame } = {}
321
+ for index = 0, steps - 1 do
322
+ local angle = math.rad(spin) * (index / (steps - 1))
323
+ local turn = CFrame.fromAxisAngle(axis.Unit, angle)
324
+ table.insert(frames, CFrame.new(pivot) * turn * CFrame.new(-pivot) * start)
325
+ end
326
+ return frames
327
+ end
328
+
329
+ local destination = parseVector(params.to)
330
+ if destination == nil then
331
+ Dispatch.fail(
332
+ "BAD_PARAMS",
333
+ "sweep needs a motion.",
334
+ "Give `to` for a slide, `spin` (with `pivot`) for a hinge, or `positions` for a path."
335
+ )
336
+ end
337
+
338
+ local frames: { CFrame } = {}
339
+ for index = 0, steps - 1 do
340
+ local alpha = index / (steps - 1)
341
+ table.insert(frames, start:Lerp(start.Rotation + (destination :: Vector3), alpha))
342
+ end
343
+ return frames
344
+ end
345
+
346
+ --[[
347
+ The volume a part passes through as it moves.
348
+
349
+ The question this answers -- "does the door hit the wall when it opens" --
350
+ has no good answer from the data model. Positions and sizes describe where
351
+ things are, not where something will be on its way somewhere else, and the
352
+ usual workaround is to move the part in steps and test at each one, which
353
+ misses anything thin enough to sit between two samples.
354
+
355
+ `checkAgainst` is why this is worth calling rather than just building the
356
+ volume: the swept solid is put in the world for a moment and asked what it
357
+ overlaps. Left on (`keep`) it stays as a part; turned off it is destroyed
358
+ once measured, so a clearance check leaves nothing behind.
359
+ ]]
360
+ function Geometry.sweep(params: { [string]: any }): { [string]: any }
361
+ local subject = resolveParts({ params.path }, "a `path`")[1]
362
+ local frames = motionOf(subject, params)
363
+
364
+ local ok, volume = pcall(function()
365
+ return (GeometryService :: any):SweepPartAsync(subject, frames, {
366
+ CollisionFidelity = Enum.CollisionFidelity[tostring(params.collisionFidelity or "Default")]
367
+ or Enum.CollisionFidelity.Default,
368
+ })
369
+ end)
370
+ if not ok or typeof(volume) ~= "Instance" then
371
+ Dispatch.fail(
372
+ "GEOMETRY_FAILED",
373
+ string.format("SweepPartAsync refused: %s", tostring(volume)),
374
+ "The part must be a solid Studio can sweep. Terrain and some mesh shapes are refused."
375
+ )
376
+ end
377
+
378
+ local swept = volume :: BasePart
379
+ local parent = if typeof(params.parent) == "string" and params.parent ~= ""
380
+ then Paths.resolve(params.parent)
381
+ else subject.Parent
382
+ local keep = params.keep ~= false
383
+
384
+ local hits: { string } = {}
385
+ local path = ""
386
+
387
+ local _, undoable = Undo.record("MCPSweep", "MCP sweep", function()
388
+ carryAppearance(subject, swept)
389
+ swept.Name = if typeof(params.name) == "string" and params.name ~= ""
390
+ then params.name
391
+ else subject.Name .. "_Sweep"
392
+ swept.Anchored = true
393
+ swept.CanCollide = false
394
+ swept.Transparency = tonumber(params.transparency) or 0.5
395
+ swept.Parent = parent
396
+
397
+ --[[
398
+ Measured while the volume is in the world, because that is the only
399
+ state the spatial query can read. The subject is excluded: a part
400
+ always overlaps its own swept volume, and reporting that would bury
401
+ the answer under the one hit nobody asked about.
402
+ ]]
403
+ local against = params.checkAgainst
404
+ if against ~= nil then
405
+ local overlap = OverlapParams.new()
406
+ overlap.MaxParts = 100
407
+ local named = if typeof(against) == "table" then against :: { any } else {}
408
+ if #named > 0 then
409
+ -- A named list is the question "does it hit THESE", so anything
410
+ -- else in the way is not an answer.
411
+ local only, _missing = Paths.resolveMany(named :: { string })
412
+ overlap.FilterType = Enum.RaycastFilterType.Include
413
+ overlap.FilterDescendantsInstances = only
414
+ else
415
+ overlap.FilterType = Enum.RaycastFilterType.Exclude
416
+ overlap.FilterDescendantsInstances = { subject, swept }
417
+ end
418
+ for _, found in workspace:GetPartsInPart(swept, overlap) do
419
+ if found ~= subject and found ~= swept then
420
+ table.insert(hits, Paths.of(found))
421
+ end
422
+ end
423
+ end
424
+
425
+ if keep then
426
+ path = Paths.of(swept)
427
+ else
428
+ swept:Destroy()
429
+ end
430
+ end)
431
+
432
+ return {
433
+ created = if keep then { path } else {},
434
+ hits = hits,
435
+ checked = params.checkAgainst ~= nil,
436
+ frames = #frames,
437
+ kept = keep,
438
+ undoable = undoable,
439
+ }
440
+ end
441
+
273
442
  function Geometry.register()
274
443
  Dispatch.registerAll("geometry", {
275
444
  combine = Geometry.combine,
276
445
  fragment = Geometry.fragment,
446
+ sweep = Geometry.sweep,
277
447
  })
278
448
  end
279
449
 
@@ -61,6 +61,44 @@ end
61
61
  inclusive, matching the numbers script_edit takes back, so a read and a write
62
62
  need no off-by-one conversion between them.
63
63
  ]]
64
+ --[[
65
+ A short fingerprint of a script's source, for detecting that it moved.
66
+
67
+ Handed out by `read` and passed back to `edit`, which refuses to write when
68
+ the live source no longer matches. That is the only thing standing between
69
+ two agents on one place and a silent overwrite: a line-range edit computed
70
+ against source somebody has since changed still applies cleanly, it just
71
+ applies to the wrong lines, and nothing anywhere reports it.
72
+
73
+ FNV-1a over the whole string, with the length appended. Not a security
74
+ hash and does not need to be -- it is guarding against ordinary concurrent
75
+ editing, not against someone constructing a collision. The length is there
76
+ because it is free and rules out the whole class of same-length accidents.
77
+
78
+ The multiply is split into 16-bit halves on purpose. `hash * 16777619` with
79
+ a 32-bit hash reaches 2^56, past the 2^53 where doubles stop being exact,
80
+ so the low bits -- the ones that carry the mixing -- would be quietly
81
+ rounded away.
82
+ ]]
83
+ local function fingerprint(source: string): string
84
+ local hash = 2166136261
85
+ local length = #source
86
+ local index = 1
87
+ while index <= length do
88
+ local last = math.min(index + 511, length)
89
+ local chunk = { string.byte(source, index, last) }
90
+ for _, byte in chunk do
91
+ hash = bit32.bxor(hash, byte)
92
+ local low = bit32.band(hash, 0xFFFF)
93
+ local high = bit32.rshift(hash, 16)
94
+ -- 16777619 == 0x01000193, so 0x0193 is 403 and 0x0100 is 256.
95
+ hash = bit32.band(low * 403 + bit32.lshift(bit32.band(high * 403 + low * 256, 0xFFFF), 16), 0xFFFFFFFF)
96
+ end
97
+ index = last + 1
98
+ end
99
+ return string.format("%08x-%x", hash, length)
100
+ end
101
+
64
102
  function Scripts.read(params: { [string]: any }): { [string]: any }
65
103
  local paths = params.paths
66
104
  if typeof(paths) ~= "table" or #paths == 0 then
@@ -98,7 +136,8 @@ function Scripts.read(params: { [string]: any }): { [string]: any }
98
136
  end
99
137
 
100
138
  local target = resolved :: LuaSourceContainer
101
- local lines = TextEdit.toLines(ScriptEdit.read(target))
139
+ local whole = ScriptEdit.read(target)
140
+ local lines = TextEdit.toLines(whole)
102
141
  local askedStart = if windowed and entry.startLine ~= nil
103
142
  then tonumber(entry.startLine)
104
143
  else tonumber(params.startLine)
@@ -118,6 +157,10 @@ function Scripts.read(params: { [string]: any }): { [string]: any }
118
157
  startLine = startLine,
119
158
  endLine = askedEnd,
120
159
  source = table.concat(window, "\n"),
160
+ -- Of the whole file, never of the window: `edit` compares it against
161
+ -- the live source, and a fingerprint of forty lines out of four
162
+ -- hundred would call an edit safe that it is not.
163
+ revision = fingerprint(whole),
121
164
  })
122
165
  end
123
166
 
@@ -171,6 +214,41 @@ function Scripts.edit(params: { [string]: any }): { [string]: any }
171
214
  local pending: { Pending } = {}
172
215
  for _, target in order do
173
216
  local before = ScriptEdit.read(target)
217
+ --[[
218
+ Refuse before transforming, not after.
219
+
220
+ An edit that names the revision it was written against is asking to
221
+ be applied to that exact text. If the file has moved on, the honest
222
+ answer is to stop: a line range still applies cleanly to changed
223
+ source, it just lands on the wrong lines, and a `source` edit throws
224
+ away everything written since it was read. Both look like success.
225
+
226
+ Checked here so the whole batch fails with nothing written, which is
227
+ the promise every other phase-one failure already makes.
228
+ ]]
229
+ local live: string? = nil
230
+ for _, edit in grouped[target] :: { TextEdit.Edit } do
231
+ local stated = (edit :: any).revision
232
+ if typeof(stated) ~= "string" or stated == "" then
233
+ continue
234
+ end
235
+ live = live or fingerprint(before)
236
+ if stated ~= live then
237
+ Dispatch.fail(
238
+ "STALE_SCRIPT",
239
+ string.format(
240
+ "%s changed since it was read (expected %s, found %s).",
241
+ target:GetFullName(),
242
+ stated,
243
+ live :: string
244
+ ),
245
+ "Somebody else edited it -- another agent, or the user typing in the "
246
+ .. "editor. Read it again with script_read and rebuild the edit "
247
+ .. "against what is there now."
248
+ )
249
+ end
250
+ end
251
+
174
252
  local after = TextEdit.apply(target:GetFullName(), before, grouped[target] :: { TextEdit.Edit })
175
253
  table.insert(pending, { target = target, before = before, after = after })
176
254
  end