@el4cteo/rbx-studio-mcp 0.1.0

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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/bridge/rpc.js +243 -0
  4. package/dist/bridge/rpc.js.map +1 -0
  5. package/dist/bridge/server.js +281 -0
  6. package/dist/bridge/server.js.map +1 -0
  7. package/dist/index.js +104 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/apidump.js +269 -0
  10. package/dist/lib/apidump.js.map +1 -0
  11. package/dist/lib/errors.js +38 -0
  12. package/dist/lib/errors.js.map +1 -0
  13. package/dist/lib/format.js +191 -0
  14. package/dist/lib/format.js.map +1 -0
  15. package/dist/lib/pluginbuild.js +83 -0
  16. package/dist/lib/pluginbuild.js.map +1 -0
  17. package/dist/lib/png.js +84 -0
  18. package/dist/lib/png.js.map +1 -0
  19. package/dist/lib/protocol.js +22 -0
  20. package/dist/lib/protocol.js.map +1 -0
  21. package/dist/lib/tool.js +27 -0
  22. package/dist/lib/tool.js.map +1 -0
  23. package/dist/resources.js +70 -0
  24. package/dist/resources.js.map +1 -0
  25. package/dist/tools/api.js +78 -0
  26. package/dist/tools/api.js.map +1 -0
  27. package/dist/tools/character.js +94 -0
  28. package/dist/tools/character.js.map +1 -0
  29. package/dist/tools/debug.js +211 -0
  30. package/dist/tools/debug.js.map +1 -0
  31. package/dist/tools/device.js +74 -0
  32. package/dist/tools/device.js.map +1 -0
  33. package/dist/tools/discover.js +217 -0
  34. package/dist/tools/discover.js.map +1 -0
  35. package/dist/tools/exec.js +191 -0
  36. package/dist/tools/exec.js.map +1 -0
  37. package/dist/tools/input.js +96 -0
  38. package/dist/tools/input.js.map +1 -0
  39. package/dist/tools/instances.js +261 -0
  40. package/dist/tools/instances.js.map +1 -0
  41. package/dist/tools/perf.js +367 -0
  42. package/dist/tools/perf.js.map +1 -0
  43. package/dist/tools/playtest.js +153 -0
  44. package/dist/tools/playtest.js.map +1 -0
  45. package/dist/tools/screenshot.js +75 -0
  46. package/dist/tools/screenshot.js.map +1 -0
  47. package/dist/tools/scripts.js +316 -0
  48. package/dist/tools/scripts.js.map +1 -0
  49. package/dist/tools/session.js +152 -0
  50. package/dist/tools/session.js.map +1 -0
  51. package/dist/tools/world.js +281 -0
  52. package/dist/tools/world.js.map +1 -0
  53. package/package.json +62 -0
  54. package/plugin/default.project.json +6 -0
  55. package/plugin/src/Config.luau +59 -0
  56. package/plugin/src/Console.luau +657 -0
  57. package/plugin/src/Context.luau +35 -0
  58. package/plugin/src/Dispatch.luau +90 -0
  59. package/plugin/src/Editor.luau +142 -0
  60. package/plugin/src/Emulation.luau +151 -0
  61. package/plugin/src/LogBuffer.luau +277 -0
  62. package/plugin/src/Net.luau +102 -0
  63. package/plugin/src/Paths.luau +255 -0
  64. package/plugin/src/Phrase.luau +465 -0
  65. package/plugin/src/Png.luau +238 -0
  66. package/plugin/src/Scope.luau +78 -0
  67. package/plugin/src/ScriptEdit.luau +100 -0
  68. package/plugin/src/Serialize.luau +287 -0
  69. package/plugin/src/TextEdit.luau +296 -0
  70. package/plugin/src/Transport.luau +328 -0
  71. package/plugin/src/Undo.luau +72 -0
  72. package/plugin/src/Visuals.luau +710 -0
  73. package/plugin/src/handlers/Api.luau +242 -0
  74. package/plugin/src/handlers/Assets.luau +145 -0
  75. package/plugin/src/handlers/Capture.luau +187 -0
  76. package/plugin/src/handlers/Character.luau +361 -0
  77. package/plugin/src/handlers/Debug.luau +391 -0
  78. package/plugin/src/handlers/Device.luau +119 -0
  79. package/plugin/src/handlers/Discover.luau +289 -0
  80. package/plugin/src/handlers/Exec.luau +270 -0
  81. package/plugin/src/handlers/Geometry.luau +261 -0
  82. package/plugin/src/handlers/Input.luau +287 -0
  83. package/plugin/src/handlers/Instances.luau +389 -0
  84. package/plugin/src/handlers/Perf.luau +645 -0
  85. package/plugin/src/handlers/Playtest.luau +205 -0
  86. package/plugin/src/handlers/Scripts.luau +387 -0
  87. package/plugin/src/handlers/Session.luau +168 -0
  88. package/plugin/src/handlers/Viewport.luau +302 -0
  89. package/plugin/src/handlers/World.luau +176 -0
  90. package/plugin/src/init.server.luau +317 -0
  91. package/scripts/build-plugin.mjs +157 -0
  92. package/scripts/check-plugin.mjs +97 -0
  93. package/scripts/install-plugin.mjs +39 -0
  94. package/scripts/latency.mjs +201 -0
  95. package/scripts/locate-luau.mjs +51 -0
  96. package/scripts/sourcemap.mjs +58 -0
  97. package/scripts/test-plugin.mjs +82 -0
@@ -0,0 +1,465 @@
1
+ --!strict
2
+ --[[
3
+ Turns a command into something a person can read at a glance.
4
+
5
+ The console used to print the wire name: `-> script.edit`, `-> instances.create`.
6
+ That tells you the plugin is alive and nothing else. Watching an agent work,
7
+ the interesting question is always "what is it touching" -- and the answer is
8
+ sitting right there in the parameters, one field away from being shown.
9
+
10
+ So `script.edit` on ServerScriptService.KillBrick becomes "Edit KillBrick",
11
+ and `instances.create` with eight children becomes "Create 8 instances in
12
+ Workspace". The subject is what the user recognises; the op is what the
13
+ protocol needed.
14
+
15
+ Three rules hold this together:
16
+
17
+ Name the subject, not the path. "KillBrick" is what someone called their
18
+ script; "ServerScriptService.Systems.KillBrick" is where it lives, and it
19
+ buries the recognisable part behind the part that never changes.
20
+
21
+ Count when there are several. "Edit 4 scripts" is worth reading. Listing
22
+ four paths in a 76-pixel-wide log line is not.
23
+
24
+ Never invent. An unknown op falls back to a tidied version of its own name
25
+ rather than a guess, because a console that lies about what ran is worse
26
+ than one that reads awkwardly.
27
+ ]]
28
+
29
+ local Phrase = {}
30
+
31
+ -- The tail of a dotted path is the bit a human named. Everything before it is
32
+ -- Roblox's filing system.
33
+ local function leaf(path: any): string?
34
+ if typeof(path) ~= "string" or path == "" then
35
+ return nil
36
+ end
37
+ local last = string.match(path, "([^%.]+)$")
38
+ if last == nil or last == "" then
39
+ return nil
40
+ end
41
+ -- Array indexing survives into the name -- "Part[3]" is meaningfully
42
+ -- different from "Part" when there are several siblings.
43
+ return last
44
+ end
45
+
46
+ local function count(value: any): number
47
+ if typeof(value) ~= "table" then
48
+ return 0
49
+ end
50
+ return #(value :: { any })
51
+ end
52
+
53
+ --[[
54
+ "1 script" / "4 scripts", without the caller assembling it each time.
55
+ ]]
56
+ local function plural(n: number, singular: string, pluralForm: string?): string
57
+ local word = if n == 1 then singular else (pluralForm or (singular .. "s"))
58
+ return string.format("%d %s", n, word)
59
+ end
60
+
61
+ --[[
62
+ Describes a list of paths: the name when there is one, a count when there are
63
+ several. This is the shape most batch tools take.
64
+ ]]
65
+ local function subjectOf(paths: any, noun: string): string
66
+ local total = count(paths)
67
+ if total == 1 then
68
+ local name = leaf((paths :: { any })[1])
69
+ if name ~= nil then
70
+ return name
71
+ end
72
+ end
73
+ if total > 1 then
74
+ return plural(total, noun)
75
+ end
76
+ return noun
77
+ end
78
+
79
+ --[[
80
+ Capitalises an op fragment for the fallback. `snapshots` -> `Snapshots`.
81
+ ]]
82
+ local function titleCase(value: string): string
83
+ return (string.upper(string.sub(value, 1, 1)) .. string.sub(value, 2))
84
+ end
85
+
86
+ type Describer = (params: { [string]: any }) -> string
87
+
88
+ --[[
89
+ One entry per op. Each reads its own parameters, because what identifies a
90
+ call differs completely between them: a script edit is about a path, a
91
+ playtest is about a mode, a find is about what was searched for.
92
+ ]]
93
+ local DESCRIBERS: { [string]: Describer } = {
94
+ ["script.read"] = function(params)
95
+ return "Read " .. subjectOf(params.paths, "script")
96
+ end,
97
+ ["script.edit"] = function(params)
98
+ local edits = params.edits
99
+ local total = count(edits)
100
+ if total == 1 then
101
+ local name = leaf((edits :: { any })[1].path)
102
+ if name ~= nil then
103
+ return "Edit " .. name
104
+ end
105
+ end
106
+ -- Several edits often land on one script, and "Edit 3 scripts" would be
107
+ -- wrong when it is three changes to one file.
108
+ local seen: { [string]: boolean } = {}
109
+ local distinct = 0
110
+ for _, edit in (edits or {}) :: { { [string]: any } } do
111
+ local name = leaf(edit.path)
112
+ if name ~= nil and not seen[name] then
113
+ seen[name] = true
114
+ distinct += 1
115
+ end
116
+ end
117
+ if distinct == 1 then
118
+ for name in seen do
119
+ return string.format("Edit %s (%s)", name, plural(total, "change"))
120
+ end
121
+ end
122
+ return string.format("Edit %s", plural(distinct, "script"))
123
+ end,
124
+ ["script.create"] = function(params)
125
+ local scripts = params.scripts
126
+ if count(scripts) == 1 then
127
+ local one = (scripts :: { any })[1]
128
+ local className = if typeof(one.className) == "string" then one.className else "Script"
129
+ return string.format("Create %s %s", one.name or "script", className)
130
+ end
131
+ return "Create " .. plural(count(scripts), "script")
132
+ end,
133
+ ["script.grep"] = function(params)
134
+ local pattern = if typeof(params.pattern) == "string" then params.pattern else "?"
135
+ return string.format('Search scripts for "%s"', pattern)
136
+ end,
137
+
138
+ ["instances.create"] = function(params)
139
+ local instances = params.instances
140
+ local total = count(instances)
141
+ if total == 1 then
142
+ local one = (instances :: { any })[1]
143
+ local name = one.name or one.className or "instance"
144
+ local where = leaf(one.parent)
145
+ return if where ~= nil
146
+ then string.format("Create %s in %s", name, where)
147
+ else "Create " .. name
148
+ end
149
+ local where = if total > 0 then leaf((instances :: { any })[1].parent) else nil
150
+ return if where ~= nil
151
+ then string.format("Create %s in %s", plural(total, "instance"), where)
152
+ else "Create " .. plural(total, "instance")
153
+ end,
154
+ ["instances.modify"] = function(params)
155
+ local targets = params.targets
156
+ -- Each target carries its own path list, so the honest count is the
157
+ -- total across them rather than the number of entries.
158
+ local total = 0
159
+ local onlyName: string? = nil
160
+ for _, target in (targets or {}) :: { { [string]: any } } do
161
+ local paths = count(target.paths)
162
+ total += paths
163
+ if paths >= 1 and onlyName == nil then
164
+ onlyName = leaf((target.paths :: { any })[1])
165
+ end
166
+ end
167
+ if total == 1 and onlyName ~= nil then
168
+ return "Modify " .. onlyName
169
+ end
170
+ return "Modify " .. plural(total, "instance")
171
+ end,
172
+ ["instances.delete"] = function(params)
173
+ return "Delete " .. subjectOf(params.paths, "instance")
174
+ end,
175
+ ["instances.move"] = function(params)
176
+ local items = params.items
177
+ local total = count(items)
178
+ local cloning = total > 0 and (items :: { any })[1].mode == "clone"
179
+ local verb = if cloning then "Clone" else "Move"
180
+ if total == 1 then
181
+ local one = (items :: { any })[1]
182
+ local what = leaf(one.path)
183
+ local where = leaf(one.to)
184
+ if what ~= nil and where ~= nil then
185
+ return string.format("%s %s to %s", verb, what, where)
186
+ end
187
+ if what ~= nil then
188
+ return verb .. " " .. what
189
+ end
190
+ end
191
+ return string.format("%s %s", verb, plural(total, "instance"))
192
+ end,
193
+
194
+ ["discover.tree"] = function(params)
195
+ local where = leaf(params.path)
196
+ return if where ~= nil then "Browse " .. where else "Browse the place"
197
+ end,
198
+ ["discover.inspect"] = function(params)
199
+ return "Inspect " .. subjectOf(params.paths, "instance")
200
+ end,
201
+ ["discover.find"] = function(params)
202
+ if typeof(params.tag) == "string" and params.tag ~= "" then
203
+ return string.format('Find things tagged "%s"', params.tag)
204
+ end
205
+ if typeof(params.nameContains) == "string" and params.nameContains ~= "" then
206
+ return string.format('Find "%s"', params.nameContains)
207
+ end
208
+ if typeof(params.className) == "string" and params.className ~= "" then
209
+ return string.format("Find every %s", params.className)
210
+ end
211
+ return "Search the place"
212
+ end,
213
+
214
+ ["script.debugSet"] = function()
215
+ return "Set breakpoints"
216
+ end,
217
+ ["debug.set"] = function(params)
218
+ local breakpoints = params.breakpoints
219
+ local total = count(breakpoints)
220
+ if total == 1 then
221
+ local one = (breakpoints :: { any })[1]
222
+ local name = leaf(one.path)
223
+ if name ~= nil then
224
+ return string.format("Break on %s:%s", name, tostring(one.line))
225
+ end
226
+ end
227
+ return "Set " .. plural(total, "breakpoint")
228
+ end,
229
+ ["debug.clear"] = function(params)
230
+ local name = leaf(params.path)
231
+ return if name ~= nil then "Clear breakpoints in " .. name else "Clear all breakpoints"
232
+ end,
233
+ ["debug.snapshots"] = function()
234
+ return "Read what the breakpoints caught"
235
+ end,
236
+ ["debug.exceptions"] = function(params)
237
+ return string.format("Break on errors: %s", tostring(params.mode or "Unhandled"))
238
+ end,
239
+
240
+ ["perf.console"] = function(params)
241
+ if typeof(params.level) == "string" and params.level ~= "" then
242
+ return string.format("Read the %s log", params.level)
243
+ end
244
+ if typeof(params.pattern) == "string" and params.pattern ~= "" then
245
+ return string.format('Search output for "%s"', params.pattern)
246
+ end
247
+ return "Read the output log"
248
+ end,
249
+ ["perf.snapshot"] = function()
250
+ return "Measure performance"
251
+ end,
252
+ ["perf.profile"] = function(params)
253
+ return string.format("Profile scripts for %ss", tostring(params.seconds or 5))
254
+ end,
255
+ ["perf.coverage"] = function(params)
256
+ local total = count(params.enable)
257
+ if total > 0 then
258
+ return "Instrument " .. plural(total, "script")
259
+ end
260
+ return "Read line coverage"
261
+ end,
262
+
263
+ ["playtest.control"] = function(params)
264
+ local op = tostring(params.op or "state")
265
+ if op == "play" then
266
+ return "Start a playtest"
267
+ elseif op == "run" then
268
+ return "Run the place"
269
+ elseif op == "multiplayer" then
270
+ return string.format("Start a %s test", plural(tonumber(params.players) or 2, "player"))
271
+ elseif op == "stop" then
272
+ return "Stop the playtest"
273
+ elseif op == "endTest" then
274
+ return "End the test"
275
+ end
276
+ return "Check the playtest"
277
+ end,
278
+
279
+ ["exec.run"] = function(params)
280
+ local source = if typeof(params.source) == "string" then params.source else ""
281
+
282
+ --[[
283
+ Says what the code is about, not what its first line says.
284
+
285
+ This used to show the opening line verbatim, which produced entries
286
+ like "Run: local GenerationService = game:GetServi..." -- a fragment
287
+ of a variable declaration, cut mid-word, telling the reader nothing
288
+ except that some code ran. The declaration is never the point of a
289
+ snippet; it is the setup before it.
290
+
291
+ So the services it touches stand in for it. That is the one thing a
292
+ snippet reliably says about itself, it is short, and it is what
293
+ someone glancing at the console actually wants: which part of Studio
294
+ is being poked.
295
+ ]]
296
+ local services: { string } = {}
297
+ local seen: { [string]: boolean } = {}
298
+ for name in string.gmatch(source, 'GetService%("([%w_]+)"%)') do
299
+ if not seen[name] then
300
+ seen[name] = true
301
+ table.insert(services, name)
302
+ end
303
+ end
304
+
305
+ local lines = 0
306
+ for _ in string.gmatch(source, "[^\r\n]+") do
307
+ lines += 1
308
+ end
309
+
310
+ if #services == 1 then
311
+ return "Run Luau on " .. services[1]
312
+ elseif #services == 2 then
313
+ return string.format("Run Luau on %s and %s", services[1], services[2])
314
+ elseif #services > 2 then
315
+ return string.format("Run Luau on %s and %d more", services[1], #services - 1)
316
+ end
317
+ return if lines <= 1 then "Run Luau" else string.format("Run Luau (%d lines)", lines)
318
+ end,
319
+
320
+ ["viewport.select"] = function(params)
321
+ local total = count(params.paths)
322
+ if total == 0 then
323
+ return "Clear the selection"
324
+ end
325
+ return "Select " .. subjectOf(params.paths, "instance")
326
+ end,
327
+ ["viewport.raycast"] = function()
328
+ return "Cast a ray"
329
+ end,
330
+
331
+ ["capture.screenshot"] = function()
332
+ return "Take a screenshot"
333
+ end,
334
+
335
+ ["viewport.focus"] = function(params)
336
+ local name = leaf(params.path)
337
+ return if name ~= nil then "Look at " .. name else "Move the camera"
338
+ end,
339
+ ["viewport.camera"] = function()
340
+ return "Move the camera"
341
+ end,
342
+
343
+ ["geometry.combine"] = function(params)
344
+ local op = tostring(params.op or "union")
345
+ local name = leaf(params.path)
346
+ local verb = if op == "subtract" then "Cut" elseif op == "intersect" then "Intersect" else "Merge"
347
+ if op == "subtract" and name ~= nil then
348
+ local from = count(params.with)
349
+ return string.format("Cut %s out of %s", plural(from, "shape"), name)
350
+ end
351
+ return if name ~= nil then string.format("%s %s", verb, name) else verb .. " parts"
352
+ end,
353
+ ["geometry.fragment"] = function(params)
354
+ local name = leaf(params.path)
355
+ return if name ~= nil
356
+ then string.format("Shatter %s into %s", name, plural(tonumber(params.pieces) or 8, "piece"))
357
+ else "Shatter a part"
358
+ end,
359
+
360
+ ["assets.insert"] = function(params)
361
+ return string.format("Insert asset %s", tostring(params.assetId or "?"))
362
+ end,
363
+
364
+ ["world.history"] = function(params)
365
+ local action = tostring(params.action or "status")
366
+ if action == "status" then
367
+ return "Check the undo history"
368
+ end
369
+ local steps = tonumber(params.steps) or 1
370
+ return string.format("%s %s", if action == "undo" then "Undo" else "Redo", plural(steps, "step"))
371
+ end,
372
+ ["world.collision"] = function(params)
373
+ local action = tostring(params.action or "list")
374
+ local group = if typeof(params.group) == "string" then params.group else "?"
375
+ if action == "list" then
376
+ return "List collision groups"
377
+ elseif action == "create" then
378
+ return "Create collision group " .. group
379
+ elseif action == "assign" then
380
+ return string.format("Put %s in %s", plural(count(params.paths), "instance"), group)
381
+ end
382
+ return string.format("Set %s against %s", group, tostring(params.with or "?"))
383
+ end,
384
+
385
+ ["character.moveTo"] = function(params)
386
+ local name = leaf(params.path)
387
+ return if name ~= nil then "Walk to " .. name else string.format("Walk to %s", tostring(params.to or "?"))
388
+ end,
389
+ ["character.act"] = function(params)
390
+ local action = tostring(params.action or "jump")
391
+ return titleCase(action) .. " the character"
392
+ end,
393
+ ["character.state"] = function()
394
+ return "Check the character"
395
+ end,
396
+
397
+ ["studio.status"] = function()
398
+ return "Check Studio"
399
+ end,
400
+ ["studio.ping"] = function()
401
+ return "Ping"
402
+ end,
403
+ }
404
+
405
+ --[[
406
+ What kind of work an op is, for anything that wants to colour it.
407
+
408
+ Grouped by what it does to the place rather than by which handler owns it,
409
+ because that is the distinction someone watching cares about: reading is
410
+ safe and constant, writing changes their game, and running executes code.
411
+ ]]
412
+ local KINDS: { [string]: string } = {
413
+ discover = "read",
414
+ studio = "read",
415
+ viewport = "read",
416
+ capture = "read",
417
+ instances = "write",
418
+ geometry = "write",
419
+ assets = "write",
420
+ world = "write",
421
+ exec = "run",
422
+ playtest = "run",
423
+ character = "run",
424
+ debug = "debug",
425
+ perf = "debug",
426
+ }
427
+
428
+ function Phrase.kindOf(op: string): string
429
+ local group = string.match(op, "^([^%.]+)%.") or op
430
+ -- Reads within the script group, writes outside it: a script edit changes
431
+ -- the game, a read or a grep does not, and they are far apart in weight.
432
+ if group == "script" then
433
+ local action = string.match(op, "%.(.+)$") or ""
434
+ return if action == "read" or action == "grep" then "read" else "write"
435
+ end
436
+ return KINDS[group] or "read"
437
+ end
438
+
439
+ --[[
440
+ The readable form of one command.
441
+
442
+ Never throws and never yields: this runs on the path that logs every call,
443
+ including the ones that are about to fail, and a console that errors while
444
+ describing an error would take the diagnosis with it.
445
+ ]]
446
+ function Phrase.of(op: string, params: { [string]: any }?): string
447
+ local describer = DESCRIBERS[op]
448
+ if describer ~= nil then
449
+ local ok, described = pcall(describer, params or {})
450
+ if ok and typeof(described) == "string" and described ~= "" then
451
+ return described
452
+ end
453
+ end
454
+
455
+ -- Unknown op, or a describer that met a shape it did not expect. Tidy the
456
+ -- wire name rather than inventing a subject for it: "instances.create"
457
+ -- becomes "Instances create", which is honest about knowing no better.
458
+ local group, action = string.match(op, "^([^%.]+)%.(.+)$")
459
+ if group ~= nil and action ~= nil then
460
+ return titleCase(group) .. " " .. action
461
+ end
462
+ return titleCase(op)
463
+ end
464
+
465
+ return Phrase
@@ -0,0 +1,238 @@
1
+ --!strict
2
+ --[[
3
+ PNG and base64, written out by hand.
4
+
5
+ Studio gives a screenshot as an EditableImage, which hands over raw RGBA
6
+ bytes and nothing else. There is no encoder anywhere in the engine's API, and
7
+ no base64 either -- `HttpService` has JSONEncode and nothing adjacent -- so
8
+ getting a picture from Studio to an agent means producing both here.
9
+
10
+ The compression is deliberately none at all. Deflate's stored mode wraps the
11
+ bytes in block headers and changes nothing else, which is legal zlib and a
12
+ valid PNG; a real compressor would take far longer to write and run, and the
13
+ image is downscaled before it reaches this point, so the saving would be on
14
+ something already small. Correctness is worth more than bytes here.
15
+ ]]
16
+
17
+ local Png = {}
18
+
19
+ -- Standard CRC-32, built once. The per-byte loop is 8 shifts; a 256-entry table
20
+ -- turns each byte into one lookup, which matters over a megabyte of pixels.
21
+ local crcTable: { number } = {}
22
+ do
23
+ for index = 0, 255 do
24
+ local value = index
25
+ for _ = 1, 8 do
26
+ if value % 2 == 1 then
27
+ value = bit32.bxor(0xEDB88320, bit32.rshift(value, 1))
28
+ else
29
+ value = bit32.rshift(value, 1)
30
+ end
31
+ end
32
+ crcTable[index] = value
33
+ end
34
+ end
35
+
36
+ local function crc32(data: buffer, from: number, count: number): number
37
+ local crc = 0xFFFFFFFF
38
+ for offset = from, from + count - 1 do
39
+ local index = bit32.band(bit32.bxor(crc, buffer.readu8(data, offset)), 0xFF)
40
+ crc = bit32.bxor(crcTable[index], bit32.rshift(crc, 8))
41
+ end
42
+ return bit32.bxor(crc, 0xFFFFFFFF)
43
+ end
44
+
45
+ --[[
46
+ Adler-32, which zlib carries and PNG readers check.
47
+
48
+ The modulo runs every 4096 bytes rather than every byte: both accumulators
49
+ stay well inside 2^53 over that span, so the arithmetic is exact and the cost
50
+ is paid a few thousand times instead of a few million.
51
+ ]]
52
+ local function adler32(data: buffer, length: number): number
53
+ local a, b = 1, 0
54
+ local index = 0
55
+ while index < length do
56
+ local chunkEnd = math.min(index + 4096, length)
57
+ while index < chunkEnd do
58
+ a += buffer.readu8(data, index)
59
+ b += a
60
+ index += 1
61
+ end
62
+ a %= 65521
63
+ b %= 65521
64
+ end
65
+ return bit32.bor(bit32.lshift(b, 16), a)
66
+ end
67
+
68
+ local function writeU32(out: buffer, offset: number, value: number)
69
+ buffer.writeu8(out, offset, bit32.band(bit32.rshift(value, 24), 0xFF))
70
+ buffer.writeu8(out, offset + 1, bit32.band(bit32.rshift(value, 16), 0xFF))
71
+ buffer.writeu8(out, offset + 2, bit32.band(bit32.rshift(value, 8), 0xFF))
72
+ buffer.writeu8(out, offset + 3, bit32.band(value, 0xFF))
73
+ end
74
+
75
+ --[[
76
+ One PNG chunk: length, type, payload, CRC over type and payload.
77
+ ]]
78
+ local function writeChunk(out: buffer, offset: number, kind: string, payload: buffer, payloadLength: number): number
79
+ writeU32(out, offset, payloadLength)
80
+ buffer.writestring(out, offset + 4, kind)
81
+ if payloadLength > 0 then
82
+ buffer.copy(out, offset + 8, payload, 0, payloadLength)
83
+ end
84
+ writeU32(out, offset + 8 + payloadLength, crc32(out, offset + 4, payloadLength + 4))
85
+ return offset + 12 + payloadLength
86
+ end
87
+
88
+ --[[
89
+ Encodes 8-bit RGB pixels as a PNG.
90
+
91
+ `rgb` holds width * height * 3 bytes, row-major, no padding. Alpha is dropped
92
+ before this point: a screenshot has none worth keeping and carrying it would
93
+ add a quarter to every byte that follows.
94
+ ]]
95
+ function Png.encode(rgb: buffer, width: number, height: number): buffer
96
+ -- PNG wants a filter byte at the start of every scanline. Filter 0 means
97
+ -- "stored as-is", which is what makes the rest of this a straight copy.
98
+ local rowBytes = width * 3
99
+ local rawLength = (rowBytes + 1) * height
100
+ local raw = buffer.create(rawLength)
101
+ for row = 0, height - 1 do
102
+ local target = row * (rowBytes + 1)
103
+ buffer.writeu8(raw, target, 0)
104
+ buffer.copy(raw, target + 1, rgb, row * rowBytes, rowBytes)
105
+ end
106
+
107
+ -- zlib: 0x78 0x01 header, stored deflate blocks, Adler-32 trailer.
108
+ local MAX_BLOCK = 65535
109
+ local blocks = math.max(1, math.ceil(rawLength / MAX_BLOCK))
110
+ local zlibLength = 2 + blocks * 5 + rawLength + 4
111
+ local zlib = buffer.create(zlibLength)
112
+ buffer.writeu8(zlib, 0, 0x78)
113
+ buffer.writeu8(zlib, 1, 0x01)
114
+
115
+ local readOffset = 0
116
+ local writeOffset = 2
117
+ for index = 1, blocks do
118
+ local size = math.min(MAX_BLOCK, rawLength - readOffset)
119
+ buffer.writeu8(zlib, writeOffset, if index == blocks then 1 else 0)
120
+ -- LEN then its one's complement, both little-endian.
121
+ buffer.writeu8(zlib, writeOffset + 1, bit32.band(size, 0xFF))
122
+ buffer.writeu8(zlib, writeOffset + 2, bit32.band(bit32.rshift(size, 8), 0xFF))
123
+ buffer.writeu8(zlib, writeOffset + 3, bit32.band(bit32.bnot(size), 0xFF))
124
+ buffer.writeu8(zlib, writeOffset + 4, bit32.band(bit32.rshift(bit32.bnot(size), 8), 0xFF))
125
+ buffer.copy(zlib, writeOffset + 5, raw, readOffset, size)
126
+ readOffset += size
127
+ writeOffset += 5 + size
128
+ end
129
+ writeU32(zlib, writeOffset, adler32(raw, rawLength))
130
+
131
+ local header = buffer.create(13)
132
+ writeU32(header, 0, width)
133
+ writeU32(header, 4, height)
134
+ buffer.writeu8(header, 8, 8) -- bit depth
135
+ buffer.writeu8(header, 9, 2) -- colour type 2: truecolour RGB
136
+ buffer.writeu8(header, 10, 0) -- deflate
137
+ buffer.writeu8(header, 11, 0) -- adaptive filtering
138
+ buffer.writeu8(header, 12, 0) -- no interlace
139
+
140
+ local total = 8 + (12 + 13) + (12 + zlibLength) + 12
141
+ local out = buffer.create(total)
142
+ -- Signature: the high bit and the CRLF pair catch transports that would
143
+ -- mangle the file without anyone noticing.
144
+ buffer.writeu8(out, 0, 0x89)
145
+ buffer.writestring(out, 1, "PNG\r\n")
146
+ buffer.writeu8(out, 6, 0x1A)
147
+ buffer.writeu8(out, 7, 0x0A)
148
+
149
+ local offset = writeChunk(out, 8, "IHDR", header, 13)
150
+ offset = writeChunk(out, offset, "IDAT", zlib, zlibLength)
151
+ writeChunk(out, offset, "IEND", buffer.create(0), 0)
152
+ return out
153
+ end
154
+
155
+ local BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
156
+
157
+ --[[
158
+ Base64, three bytes at a time.
159
+
160
+ Assembled into a table of chunks and concatenated once at the end. Appending
161
+ to a string in a loop would rebuild the whole thing on every pass, which over
162
+ a megabyte is the difference between prompt and unusable.
163
+ ]]
164
+ function Png.base64(data: buffer): string
165
+ local length = buffer.len(data)
166
+ local pieces: { string } = {}
167
+ local index = 0
168
+
169
+ while index + 3 <= length do
170
+ local a, b, c = buffer.readu8(data, index), buffer.readu8(data, index + 1), buffer.readu8(data, index + 2)
171
+ local packed = bit32.bor(bit32.lshift(a, 16), bit32.lshift(b, 8), c)
172
+ table.insert(
173
+ pieces,
174
+ string.sub(BASE64, bit32.rshift(packed, 18) + 1, bit32.rshift(packed, 18) + 1)
175
+ .. string.sub(BASE64, bit32.band(bit32.rshift(packed, 12), 63) + 1, bit32.band(bit32.rshift(packed, 12), 63) + 1)
176
+ .. string.sub(BASE64, bit32.band(bit32.rshift(packed, 6), 63) + 1, bit32.band(bit32.rshift(packed, 6), 63) + 1)
177
+ .. string.sub(BASE64, bit32.band(packed, 63) + 1, bit32.band(packed, 63) + 1)
178
+ )
179
+ index += 3
180
+ end
181
+
182
+ local remaining = length - index
183
+ if remaining == 1 then
184
+ local a = buffer.readu8(data, index)
185
+ local packed = bit32.lshift(a, 16)
186
+ table.insert(
187
+ pieces,
188
+ string.sub(BASE64, bit32.rshift(packed, 18) + 1, bit32.rshift(packed, 18) + 1)
189
+ .. string.sub(BASE64, bit32.band(bit32.rshift(packed, 12), 63) + 1, bit32.band(bit32.rshift(packed, 12), 63) + 1)
190
+ .. "=="
191
+ )
192
+ elseif remaining == 2 then
193
+ local a, b = buffer.readu8(data, index), buffer.readu8(data, index + 1)
194
+ local packed = bit32.bor(bit32.lshift(a, 16), bit32.lshift(b, 8))
195
+ table.insert(
196
+ pieces,
197
+ string.sub(BASE64, bit32.rshift(packed, 18) + 1, bit32.rshift(packed, 18) + 1)
198
+ .. string.sub(BASE64, bit32.band(bit32.rshift(packed, 12), 63) + 1, bit32.band(bit32.rshift(packed, 12), 63) + 1)
199
+ .. string.sub(BASE64, bit32.band(bit32.rshift(packed, 6), 63) + 1, bit32.band(bit32.rshift(packed, 6), 63) + 1)
200
+ .. "="
201
+ )
202
+ end
203
+
204
+ return table.concat(pieces)
205
+ end
206
+
207
+ --[[
208
+ Nearest-neighbour downscale, RGBA in and RGB out.
209
+
210
+ Nearest rather than averaged on purpose. A screenshot is read for what is in
211
+ it -- is the part there, is the GUI covering it, is the material wrong -- and
212
+ averaging softens exactly the thin edges and one-pixel text that carry that.
213
+ It is also a single read per output pixel instead of four.
214
+ ]]
215
+ function Png.downscaleToRgb(rgba: buffer, width: number, height: number, targetWidth: number): (buffer, number, number)
216
+ local scale = math.min(1, targetWidth / width)
217
+ local outWidth = math.max(1, math.floor(width * scale))
218
+ local outHeight = math.max(1, math.floor(height * scale))
219
+ local out = buffer.create(outWidth * outHeight * 3)
220
+
221
+ for row = 0, outHeight - 1 do
222
+ local sourceRow = math.min(height - 1, math.floor(row / scale))
223
+ local sourceRowBase = sourceRow * width * 4
224
+ local targetRowBase = row * outWidth * 3
225
+ for column = 0, outWidth - 1 do
226
+ local sourceColumn = math.min(width - 1, math.floor(column / scale))
227
+ local source = sourceRowBase + sourceColumn * 4
228
+ local target = targetRowBase + column * 3
229
+ buffer.writeu8(out, target, buffer.readu8(rgba, source))
230
+ buffer.writeu8(out, target + 1, buffer.readu8(rgba, source + 1))
231
+ buffer.writeu8(out, target + 2, buffer.readu8(rgba, source + 2))
232
+ end
233
+ end
234
+
235
+ return out, outWidth, outHeight
236
+ end
237
+
238
+ return Png