@el4cteo/rbx-studio-mcp 0.7.8 → 0.8.2

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 (81) hide show
  1. package/README.md +175 -175
  2. package/dist/bridge/harness.js +14 -5
  3. package/dist/bridge/harness.js.map +1 -1
  4. package/dist/index.js +5 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/apidump.js +62 -25
  7. package/dist/lib/apidump.js.map +1 -1
  8. package/dist/lib/format.js +43 -4
  9. package/dist/lib/format.js.map +1 -1
  10. package/dist/lib/livedata.js +24 -1
  11. package/dist/lib/livedata.js.map +1 -1
  12. package/dist/lib/liveops.js +79 -0
  13. package/dist/lib/liveops.js.map +1 -1
  14. package/dist/lib/monetization.js +119 -0
  15. package/dist/lib/monetization.js.map +1 -0
  16. package/dist/lib/opencloud.js +16 -6
  17. package/dist/lib/opencloud.js.map +1 -1
  18. package/dist/lib/pluginbuild.js +10 -0
  19. package/dist/lib/pluginbuild.js.map +1 -1
  20. package/dist/lib/png.js +104 -0
  21. package/dist/lib/png.js.map +1 -1
  22. package/dist/resources.js +3 -2
  23. package/dist/resources.js.map +1 -1
  24. package/dist/tools/api.js +4 -3
  25. package/dist/tools/api.js.map +1 -1
  26. package/dist/tools/audio.js +1 -1
  27. package/dist/tools/audio.js.map +1 -1
  28. package/dist/tools/debug.js +2 -2
  29. package/dist/tools/debug.js.map +1 -1
  30. package/dist/tools/device.js +10 -9
  31. package/dist/tools/device.js.map +1 -1
  32. package/dist/tools/discover.js +15 -4
  33. package/dist/tools/discover.js.map +1 -1
  34. package/dist/tools/exec.js +4 -4
  35. package/dist/tools/exec.js.map +1 -1
  36. package/dist/tools/instances.js +32 -7
  37. package/dist/tools/instances.js.map +1 -1
  38. package/dist/tools/perf.js +27 -5
  39. package/dist/tools/perf.js.map +1 -1
  40. package/dist/tools/playtest.js +35 -10
  41. package/dist/tools/playtest.js.map +1 -1
  42. package/dist/tools/screenshot.js +55 -11
  43. package/dist/tools/screenshot.js.map +1 -1
  44. package/dist/tools/scripts.js +4 -4
  45. package/dist/tools/scripts.js.map +1 -1
  46. package/dist/tools/terrain.js +5 -5
  47. package/dist/tools/terrain.js.map +1 -1
  48. package/dist/tools/universe.js +136 -11
  49. package/dist/tools/universe.js.map +1 -1
  50. package/dist/tools/world.js +38 -15
  51. package/dist/tools/world.js.map +1 -1
  52. package/package.json +75 -75
  53. package/plugin/src/ClientRelay.luau +20 -2
  54. package/plugin/src/Config.luau +1 -1
  55. package/plugin/src/Emulation.luau +44 -16
  56. package/plugin/src/ExecRuntime.luau +168 -161
  57. package/plugin/src/LogBuffer.luau +65 -3
  58. package/plugin/src/Paths.luau +15 -4
  59. package/plugin/src/Phrase.luau +3 -20
  60. package/plugin/src/ScriptEdit.luau +14 -0
  61. package/plugin/src/Serialize.luau +17 -0
  62. package/plugin/src/TextEdit.luau +6 -0
  63. package/plugin/src/handlers/Api.luau +8 -0
  64. package/plugin/src/handlers/Assets.luau +27 -18
  65. package/plugin/src/handlers/Audio.luau +36 -15
  66. package/plugin/src/handlers/Capture.luau +219 -3
  67. package/plugin/src/handlers/Character.luau +38 -23
  68. package/plugin/src/handlers/Data.luau +12 -1
  69. package/plugin/src/handlers/Device.luau +38 -27
  70. package/plugin/src/handlers/Generate.luau +8 -5
  71. package/plugin/src/handlers/Geometry.luau +7 -5
  72. package/plugin/src/handlers/Perf.luau +1 -0
  73. package/plugin/src/handlers/Playtest.luau +40 -3
  74. package/plugin/src/handlers/Terrain.luau +9 -24
  75. package/plugin/src/handlers/Viewport.luau +4 -4
  76. package/plugin/src/handlers/World.luau +17 -1
  77. package/plugin/src/init.server.luau +0 -2
  78. package/scripts/test-tools.mjs +437 -216
  79. package/dist/tools/anim.js +0 -159
  80. package/dist/tools/anim.js.map +0 -1
  81. package/plugin/src/handlers/Anim.luau +0 -897
@@ -1,161 +1,168 @@
1
- --!strict
2
- -- Shared bounded execution results in the VM that calls run.
3
- local LogService = game:GetService("LogService")
4
- local Serialize = require(script.Parent.Serialize)
5
- -- A chunk that prints in a loop would otherwise return a response no context
6
- -- window can hold.
7
- local MAX_OUTPUT_LINES = 200
8
- local MAX_RETURN_VALUES = 10
9
-
10
- -- Returned tables are walked rather than counted, but not without limit: a
11
- -- chunk that returns a whole config tree, or one with a cycle in it, must not be
12
- -- able to produce a response nothing can read.
13
- local MAX_TABLE_ENTRIES = 50
14
- local MAX_TABLE_DEPTH = 4
15
-
16
- local function describe(value: any, depth: number?, seen: { [any]: boolean }?): any
17
- local level = depth or 0
18
- local visited = seen or {}
19
- local kind = typeof(value)
20
-
21
- if kind == "function" or kind == "thread" then
22
- return string.format("<%s>", kind)
23
- end
24
- if kind ~= "table" then
25
- return Serialize.value(value)
26
- end
27
-
28
- local source = value :: { [any]: any }
29
- -- Named separately from the depth cap, because they mean opposite things to
30
- -- a reader. The depth cap says "there is more below this"; a cycle says
31
- -- "this is the table you are already inside". Reporting the second as the
32
- -- first printed `t.self.self.self` as three distinct nested tables, which
33
- -- reads as real structure that does not exist.
34
- if visited[source] then
35
- return "<circular reference>"
36
- end
37
- if level >= MAX_TABLE_DEPTH then
38
- return "<nested table>"
39
- end
40
- visited[source] = true
41
-
42
- local count = 0
43
- for _ in source do
44
- count += 1
45
- end
46
-
47
- -- Arrays keep their shape so they read as lists on the other side; anything
48
- -- with non-sequential keys becomes a map with stringified keys.
49
- if count == #source then
50
- local list: { any } = {}
51
- for index, item in ipairs(source) do
52
- if index > MAX_TABLE_ENTRIES then
53
- table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
54
- break
55
- end
56
- table.insert(list, describe(item, level + 1, visited))
57
- end
58
- -- Cleared on the way out so the same table appearing twice side by side
59
- -- still renders twice; only a table containing itself is a cycle.
60
- visited[source] = nil
61
- return list
62
- end
63
-
64
- local map: { [string]: any } = {}
65
- local shown = 0
66
- for key, item in source do
67
- shown += 1
68
- if shown > MAX_TABLE_ENTRIES then
69
- map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
70
- break
71
- end
72
- map[tostring(key)] = describe(item, level + 1, visited)
73
- end
74
- visited[source] = nil
75
- return map
76
- end
77
-
78
- local Runtime = {}
79
- Runtime.describe = describe
80
- function Runtime.run(chunk: () -> ...any, timeout: number?): { [string]: any }
81
- -- Captured rather than inferred: a chunk's prints are usually the point, and
82
- -- reading them back from the log afterwards would also pick up whatever else
83
- -- the place logged in the meantime.
84
- local output: { { [string]: any } } = {}
85
- local connection = LogService.MessageOut:Connect(function(message, messageType)
86
- if #output < MAX_OUTPUT_LINES then
87
- table.insert(output, {
88
- message = message,
89
- level = if messageType == Enum.MessageType.MessageError
90
- then "error"
91
- elseif messageType == Enum.MessageType.MessageWarning then "warning"
92
- else "print",
93
- })
94
- end
95
- end)
96
-
97
- local started = os.clock()
98
- local results: any = nil
99
- if timeout then
100
- local worker = task.spawn(function() results = table.pack(pcall(chunk :: any)) end)
101
- local deadline = started + timeout
102
- while results == nil and os.clock() < deadline do task.wait() end
103
- if results == nil then
104
- task.cancel(worker)
105
- results = table.pack(false, "CLIENT_TIMEOUT: execution exceeded " .. tostring(timeout) .. "s")
106
- end
107
- else
108
- results = table.pack(pcall(chunk :: () -> ...any))
109
- end
110
- local elapsed = os.clock() - started
111
-
112
- --[[
113
- MessageOut is deferred: `print` returns before the event fires, so
114
- disconnecting straight after the chunk finished captured nothing at all.
115
- A short settle picks the lines up.
116
-
117
- It is bounded and stops as soon as the flow stops, rather than waiting a
118
- fixed period, so a chunk that logged nothing costs almost no time and one
119
- that logged plenty is not truncated.
120
- ]]
121
- local settleDeadline = os.clock() + 0.5
122
- local seen = #output
123
- local quietFrames = 0
124
- while os.clock() < settleDeadline and quietFrames < 3 do
125
- task.wait()
126
- if #output == seen then
127
- quietFrames += 1
128
- else
129
- seen = #output
130
- quietFrames = 0
131
- end
132
- end
133
-
134
- connection:Disconnect()
135
-
136
- if not results[1] then
137
- return {
138
- ok = false,
139
- error = tostring(results[2]),
140
- output = output,
141
- milliseconds = math.floor(elapsed * 1000 + 0.5),
142
- }
143
- end
144
-
145
- local returned: { any } = {}
146
- for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
147
- -- A nil return is written out rather than dropped. `table.insert` of nil
148
- -- does nothing, so `return a, nil, b` came back as two values and every
149
- -- one after the nil shifted into the wrong position.
150
- local value = results[index]
151
- table.insert(returned, if value == nil then "nil" else describe(value))
152
- end
153
-
154
- return {
155
- ok = true,
156
- returned = returned,
157
- output = output,
158
- milliseconds = math.floor(elapsed * 1000 + 0.5),
159
- }
160
- end
161
- return Runtime
1
+ --!strict
2
+ -- Shared bounded execution results in the VM that calls run.
3
+ local LogService = game:GetService("LogService")
4
+ local LogBuffer = require(script.Parent.LogBuffer)
5
+ local Serialize = require(script.Parent.Serialize)
6
+ -- A chunk that prints in a loop would otherwise return a response no context
7
+ -- window can hold.
8
+ local MAX_OUTPUT_LINES = 200
9
+ local MAX_RETURN_VALUES = 10
10
+
11
+ -- Returned tables are walked rather than counted, but not without limit: a
12
+ -- chunk that returns a whole config tree, or one with a cycle in it, must not be
13
+ -- able to produce a response nothing can read.
14
+ local MAX_TABLE_ENTRIES = 50
15
+ local MAX_TABLE_DEPTH = 4
16
+
17
+ local function describe(value: any, depth: number?, seen: { [any]: boolean }?): any
18
+ local level = depth or 0
19
+ local visited = seen or {}
20
+ local kind = typeof(value)
21
+
22
+ if kind == "function" or kind == "thread" then
23
+ return string.format("<%s>", kind)
24
+ end
25
+ if kind ~= "table" then
26
+ return Serialize.value(value)
27
+ end
28
+
29
+ local source = value :: { [any]: any }
30
+ -- Named separately from the depth cap, because they mean opposite things to
31
+ -- a reader. The depth cap says "there is more below this"; a cycle says
32
+ -- "this is the table you are already inside". Reporting the second as the
33
+ -- first printed `t.self.self.self` as three distinct nested tables, which
34
+ -- reads as real structure that does not exist.
35
+ if visited[source] then
36
+ return "<circular reference>"
37
+ end
38
+ if level >= MAX_TABLE_DEPTH then
39
+ return "<nested table>"
40
+ end
41
+ visited[source] = true
42
+
43
+ local count = 0
44
+ for _ in source do
45
+ count += 1
46
+ end
47
+
48
+ -- Arrays keep their shape so they read as lists on the other side; anything
49
+ -- with non-sequential keys becomes a map with stringified keys.
50
+ if count == #source then
51
+ local list: { any } = {}
52
+ for index, item in ipairs(source) do
53
+ if index > MAX_TABLE_ENTRIES then
54
+ table.insert(list, string.format("<%d more>", count - MAX_TABLE_ENTRIES))
55
+ break
56
+ end
57
+ table.insert(list, describe(item, level + 1, visited))
58
+ end
59
+ -- Cleared on the way out so the same table appearing twice side by side
60
+ -- still renders twice; only a table containing itself is a cycle.
61
+ visited[source] = nil
62
+ return list
63
+ end
64
+
65
+ local map: { [string]: any } = {}
66
+ local shown = 0
67
+ for key, item in source do
68
+ shown += 1
69
+ if shown > MAX_TABLE_ENTRIES then
70
+ map["..."] = string.format("<%d more>", count - MAX_TABLE_ENTRIES)
71
+ break
72
+ end
73
+ map[tostring(key)] = describe(item, level + 1, visited)
74
+ end
75
+ visited[source] = nil
76
+ return map
77
+ end
78
+
79
+ local Runtime = {}
80
+ Runtime.describe = describe
81
+ function Runtime.run(chunk: () -> ...any, timeout: number?): { [string]: any }
82
+ -- Captured rather than inferred: a chunk's prints are usually the point, and
83
+ -- reading them back from the log afterwards would also pick up whatever else
84
+ -- the place logged in the meantime.
85
+ local output: { { [string]: any } } = {}
86
+ local connection = LogService.MessageOut:Connect(function(message, messageType, context)
87
+ if #output < MAX_OUTPUT_LINES then
88
+ -- A structured log's context rides on the line, as it does in `console`.
89
+ local encoded = LogBuffer.encodeContext(context)
90
+ if encoded then
91
+ message ..= " " .. encoded
92
+ end
93
+ table.insert(output, {
94
+ message = message,
95
+ level = if messageType == Enum.MessageType.MessageError
96
+ then "error"
97
+ elseif messageType == Enum.MessageType.MessageWarning then "warning"
98
+ elseif messageType == Enum.MessageType.MessageInfo then "info"
99
+ else "print",
100
+ })
101
+ end
102
+ end)
103
+
104
+ local started = os.clock()
105
+ local results: any = nil
106
+ if timeout then
107
+ local worker = task.spawn(function() results = table.pack(pcall(chunk :: any)) end)
108
+ local deadline = started + timeout
109
+ while results == nil and os.clock() < deadline do task.wait() end
110
+ if results == nil then
111
+ task.cancel(worker)
112
+ results = table.pack(false, "CLIENT_TIMEOUT: execution exceeded " .. tostring(timeout) .. "s")
113
+ end
114
+ else
115
+ results = table.pack(pcall(chunk :: () -> ...any))
116
+ end
117
+ local elapsed = os.clock() - started
118
+
119
+ --[[
120
+ MessageOut is deferred: `print` returns before the event fires, so
121
+ disconnecting straight after the chunk finished captured nothing at all.
122
+ A short settle picks the lines up.
123
+
124
+ It is bounded and stops as soon as the flow stops, rather than waiting a
125
+ fixed period, so a chunk that logged nothing costs almost no time and one
126
+ that logged plenty is not truncated.
127
+ ]]
128
+ local settleDeadline = os.clock() + 0.5
129
+ local seen = #output
130
+ local quietFrames = 0
131
+ while os.clock() < settleDeadline and quietFrames < 3 do
132
+ task.wait()
133
+ if #output == seen then
134
+ quietFrames += 1
135
+ else
136
+ seen = #output
137
+ quietFrames = 0
138
+ end
139
+ end
140
+
141
+ connection:Disconnect()
142
+
143
+ if not results[1] then
144
+ return {
145
+ ok = false,
146
+ error = tostring(results[2]),
147
+ output = output,
148
+ milliseconds = math.floor(elapsed * 1000 + 0.5),
149
+ }
150
+ end
151
+
152
+ local returned: { any } = {}
153
+ for index = 2, math.min(results.n, MAX_RETURN_VALUES + 1) do
154
+ -- A nil return is written out rather than dropped. `table.insert` of nil
155
+ -- does nothing, so `return a, nil, b` came back as two values and every
156
+ -- one after the nil shifted into the wrong position.
157
+ local value = results[index]
158
+ table.insert(returned, if value == nil then "nil" else describe(value))
159
+ end
160
+
161
+ return {
162
+ ok = true,
163
+ returned = returned,
164
+ output = output,
165
+ milliseconds = math.floor(elapsed * 1000 + 0.5),
166
+ }
167
+ end
168
+ return Runtime
@@ -35,6 +35,10 @@ local SLACK = 500
35
35
  -- handful of entries is a mismatch rather than a late delivery.
36
36
  local CORRELATION_WINDOW = 20
37
37
 
38
+ -- A structured log's context, as JSON, is cut here: Roblox's own server logs
39
+ -- drop context past 1KiB, so nothing longer is worth carrying.
40
+ local MAX_CONTEXT = 1000
41
+
38
42
  export type Entry = {
39
43
  sequence: number?,
40
44
  level: string,
@@ -43,6 +47,8 @@ export type Entry = {
43
47
  -- Present on errors once the matching stack trace has been correlated.
44
48
  stack: string?,
45
49
  source: string?,
50
+ -- The table a structured log (`LogService:Info(msg, context)`) carried, as JSON.
51
+ context: string?,
46
52
  }
47
53
 
48
54
  local LEVELS: { [number]: string } = {
@@ -107,6 +113,13 @@ end
107
113
  -- Each player gets a separate stream and token. Rejoining, reconnecting to a
108
114
  -- different server DataModel, or a plugin reload invalidates old cursors.
109
115
  function LogBuffer.startClient(player: Player)
116
+ -- A relay reinstalled after a respawn is the same player in the same session:
117
+ -- keep its log and cursor. Only a player that left (stopClient) starts fresh.
118
+ local existing = clientBuffers[player]
119
+ if existing then
120
+ existing.ready = false
121
+ return
122
+ end
110
123
  clientBuffers[player] = { token = HttpService:GenerateGUID(false), entries = {}, dropped = 0, sequence = 0, startedAt = os.time(), ready = false, orphan = {} }
111
124
  end
112
125
 
@@ -146,6 +159,7 @@ function LogBuffer.pushClient(player: Player, payload: any)
146
159
  timestamp = if typeof(payload.timestamp) == "number" and payload.timestamp >= 0 and payload.timestamp < 100000000000 then payload.timestamp else os.time(),
147
160
  stack = if typeof(payload.stack) == "string" then string.sub(payload.stack, 1, 4000) else nil,
148
161
  source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil,
162
+ context = if typeof(payload.context) == "string" then string.sub(payload.context, 1, MAX_CONTEXT) else nil,
149
163
  }
150
164
  local waiting = buffer.orphan[entry.message]
151
165
  if level == "error" and waiting then
@@ -176,12 +190,60 @@ function LogBuffer.stream(player: Player?): ({ Entry }, number, string, number,
176
190
  return table.move(entries, 1, #entries, 1, {}), dropped, studioToken, nextSequence, LogBuffer.recordingSeconds()
177
191
  end
178
192
 
179
- local function push(message: string, messageType: Enum.MessageType, timestamp: number?)
193
+ --[[
194
+ The context table of a structured log, as compact JSON.
195
+
196
+ `LogService:Info("{player} leveled up", { player = "Bob" })` prints only the
197
+ filled-in message; the table is the part that says which player and what
198
+ state, and MessageOut hands it over as a third argument. Plain prints pass an
199
+ empty table, which is dropped. Capped at 1000 characters -- the ceiling
200
+ Roblox's own server logs apply -- and a table JSON cannot hold (a Vector3, an
201
+ Instance) falls back to text rather than being lost.
202
+ ]]
203
+ -- JSONEncode writes a Vector3 or an Instance as `null` without complaint, so
204
+ -- anything JSON has no shape for is turned into its text first.
205
+ local function plain(value: any, depth: number): any
206
+ local kind = typeof(value)
207
+ if kind == "string" or kind == "number" or kind == "boolean" then
208
+ return value
209
+ elseif kind == "table" then
210
+ if depth >= 4 then
211
+ return "<table>"
212
+ end
213
+ local copy = {}
214
+ for key, item in value do
215
+ copy[if typeof(key) == "number" then key else tostring(key)] = plain(item, depth + 1)
216
+ end
217
+ return copy
218
+ elseif kind == "Instance" then
219
+ local ok, full = pcall(function()
220
+ return value:GetFullName()
221
+ end)
222
+ return if ok then full else value.Name
223
+ end
224
+ return tostring(value)
225
+ end
226
+
227
+ function LogBuffer.encodeContext(context: any): string?
228
+ if typeof(context) ~= "table" or next(context) == nil then
229
+ return nil
230
+ end
231
+ local ok, encoded = pcall(function()
232
+ return HttpService:JSONEncode(plain(context, 0))
233
+ end)
234
+ if not ok then
235
+ return nil
236
+ end
237
+ return string.sub(encoded :: string, 1, MAX_CONTEXT)
238
+ end
239
+
240
+ local function push(message: string, messageType: Enum.MessageType, timestamp: number?, context: any?)
180
241
  local level = LEVELS[messageType.Value] or "print"
181
242
  local entry: Entry = {
182
243
  level = level,
183
244
  message = message,
184
245
  timestamp = timestamp or os.time(),
246
+ context = LogBuffer.encodeContext(context),
185
247
  }
186
248
 
187
249
  -- Claim a trace that beat its own error line here.
@@ -254,7 +316,7 @@ function LogBuffer.start()
254
316
  end
255
317
  end
256
318
 
257
- LogService.MessageOut:Connect(function(message: string, messageType: Enum.MessageType)
319
+ LogService.MessageOut:Connect(function(message: string, messageType: Enum.MessageType, context: any?)
258
320
  local buffered = collecting
259
321
 
260
322
  if buffered then
@@ -298,7 +360,7 @@ function LogBuffer.start()
298
360
  return
299
361
  end
300
362
 
301
- push(message, messageType)
363
+ push(message, messageType, nil, context)
302
364
  end)
303
365
 
304
366
  --[[
@@ -147,13 +147,19 @@ local function childBySegment(
147
147
 
148
148
  if ordinal then
149
149
  local list = childrenByName(parent, nil)[name]
150
- if list == nil then
151
- return nil, nil
152
- end
153
- local child = list[ordinal]
150
+ local child = if list then list[ordinal] else nil
154
151
  if child then
155
152
  return child, nil
156
153
  end
154
+ -- Not an index after all: a child literally named "x[1]". Typed by hand,
155
+ -- or from a path emitted before `Paths.of` began marking such names.
156
+ local literal = parent:FindFirstChild(segment)
157
+ if literal then
158
+ return literal, nil
159
+ end
160
+ if list == nil then
161
+ return nil, nil
162
+ end
157
163
  return nil,
158
164
  string.format(
159
165
  '"%s" has %d child%s named "%s", so [%d] is past the end. Indexes are '
@@ -343,6 +349,11 @@ function Paths.of(instance: Instance, memo: NameIndex?): string
343
349
  while current ~= nil and current ~= game do
344
350
  local node = current :: Instance
345
351
  local ordinal = siblingIndex(node, memo)
352
+ -- A name that itself ends in "[n]" would read back as an index into a
353
+ -- shorter name. An explicit index keeps it literal: "x[1][1]".
354
+ if ordinal == nil and string.match(node.Name, "%[%d+%]$") then
355
+ ordinal = 1
356
+ end
346
357
  table.insert(
347
358
  segments,
348
359
  1,
@@ -341,6 +341,8 @@ local DESCRIBERS: { [string]: Describer } = {
341
341
  return "Run the place"
342
342
  elseif op == "multiplayer" then
343
343
  return string.format("Start a %s test", plural(tonumber(params.players) or 2, "player"))
344
+ elseif op == "addPlayers" then
345
+ return string.format("Add %s to the test", plural(tonumber(params.players) or 1, "player"))
344
346
  elseif op == "stop" then
345
347
  return "Stop the playtest"
346
348
  elseif op == "endTest" then
@@ -566,29 +568,11 @@ local DESCRIBERS: { [string]: Describer } = {
566
568
 
567
569
  --[[
568
570
  Everything below was added after the first pass over this table, and each
569
- one spent time showing as the fallback -- "Anim preview", "Data set",
571
+ one spent time showing as the fallback -- "Data set",
570
572
  "Terrain fill". That form is honest but says nothing about the subject,
571
573
  which is the whole point of this module: the panel is watched to see WHAT
572
574
  an agent is touching, and 26 of 72 operations were declining to say.
573
575
  ]]
574
- ["anim.read"] = function(params)
575
- local name = leaf(params.assetId)
576
- return if name ~= nil then "Read the animation " .. name else "Read an animation"
577
- end,
578
- ["anim.build"] = function(params)
579
- local frames = count(params.keyframes)
580
- return string.format("Build an animation from %s", plural(frames, "keyframe"))
581
- end,
582
- ["anim.preview"] = function(params)
583
- local rig = leaf(params.rig) or "a rig"
584
- if params.op == "stop" then
585
- return "Clear the pose on " .. rig
586
- end
587
- local at = tonumber(params.at)
588
- return if at ~= nil
589
- then string.format("Pose %s at %.2fs", rig, at)
590
- else "Pose " .. rig
591
- end,
592
576
 
593
577
  ["data.list"] = function(params)
594
578
  local store = params.store
@@ -771,7 +755,6 @@ local KINDS: { [string]: string } = {
771
755
  ]]
772
756
  terrain = "write",
773
757
  generate = "write",
774
- anim = "write",
775
758
  data = "write",
776
759
  }
777
760
 
@@ -67,6 +67,20 @@ local function confirm(target: LuaSourceContainer, produced: string?)
67
67
  return
68
68
  end
69
69
 
70
+ --[[
71
+ No document, no race. The overwrite comes from an editor tab that was
72
+ loading while we wrote; a tab opened afterwards loads the new source. So
73
+ a script nobody has open is confirmed by one read, instead of paying the
74
+ settle -- which cost every written script 200ms, one after another, on
75
+ every script_edit and script_create.
76
+ ]]
77
+ local found, document = pcall(function()
78
+ return ScriptEditorService:FindScriptDocument(target)
79
+ end)
80
+ if found and document == nil and ScriptEdit.read(target) == produced then
81
+ return
82
+ end
83
+
70
84
  -- Three rounds: the race is with one load, so a single rewrite settles it,
71
85
  -- and a script that discards three identical writes is failing for a reason
72
86
  -- repeating will not fix.
@@ -403,6 +403,11 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
403
403
  return true, Vector2.new(numbers[1], numbers[2]), nil
404
404
  end
405
405
  if valueType == "Color3" then
406
+ -- "#FF8800", the way colours are usually written everywhere else.
407
+ local hex = if kind == "string" then string.match(text, "^%s*#(%x%x%x%x%x%x)%s*$") else nil
408
+ if hex then
409
+ return true, Color3.fromHex(hex), nil
410
+ end
406
411
  if #numbers < 3 then
407
412
  return false, nil, 'expected three 0-1 components, e.g. "1, 0.5, 0"'
408
413
  end
@@ -435,6 +440,18 @@ function Serialize.parse(text: any, valueType: string): (boolean, any, string?)
435
440
  return true, UDim.new(numbers[1], numbers[2]), nil
436
441
  end
437
442
  if valueType == "CFrame" then
443
+ -- Twelve numbers are a full matrix -- what `tostring(cframe)` prints. Read
444
+ -- as "position | rotation" they gave a silently wrong orientation.
445
+ if #numbers == 12 then
446
+ return true,
447
+ CFrame.new(
448
+ numbers[1], numbers[2], numbers[3],
449
+ numbers[4], numbers[5], numbers[6],
450
+ numbers[7], numbers[8], numbers[9],
451
+ numbers[10], numbers[11], numbers[12]
452
+ ),
453
+ nil
454
+ end
438
455
  if #numbers >= 6 then
439
456
  return true,
440
457
  CFrame.new(numbers[1], numbers[2], numbers[3])
@@ -263,6 +263,12 @@ function TextEdit.apply(path: string, source: string, edits: { Edit }): string
263
263
  end
264
264
 
265
265
  local text = table.concat(lines, "\n")
266
+ -- `toLines` drops the final newline, so it is put back here. Without this
267
+ -- every edit -- even a find/replace in the middle -- stripped it silently,
268
+ -- and a `find` that included it could never match.
269
+ if string.sub(source, -1) == "\n" and #lines > 0 then
270
+ text ..= "\n"
271
+ end
266
272
 
267
273
  for _, edit in findEdits do
268
274
  local needle = edit.find :: string
@@ -122,6 +122,14 @@ local function collect(entries: { any }, className: string, wantInherited: boole
122
122
  deprecated += 1
123
123
  continue
124
124
  end
125
+ -- No permits at all means no script can touch it: Part's old `shap` and
126
+ -- `shape` aliases, or engine-only calls like GetPhysicsCost. The engine
127
+ -- does not flag those as deprecated, so they are held back here too.
128
+ local permits = if typeof(entry) == "table" then (entry :: any).Permits else nil
129
+ if typeof(permits) == "table" and next(permits) == nil then
130
+ deprecated += 1
131
+ continue
132
+ end
125
133
  local own = typeof(entry) ~= "table" or tostring((entry :: any).Owner) == className
126
134
  if not own then
127
135
  inherited += 1