@el4cteo/rbx-studio-mcp 0.6.0 → 0.6.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.
- package/README.md +4 -4
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/tools/anim.js +159 -0
- package/dist/tools/anim.js.map +1 -0
- package/dist/tools/character.js +95 -5
- package/dist/tools/character.js.map +1 -1
- package/dist/tools/data.js +173 -0
- package/dist/tools/data.js.map +1 -0
- package/dist/tools/device.js +77 -7
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +80 -4
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +48 -1
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +35 -9
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/perf.js +74 -7
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/scripts.js +51 -3
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/world.js +317 -44
- package/dist/tools/world.js.map +1 -1
- package/package.json +74 -74
- package/plugin/src/Commands.luau +622 -562
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Console.luau +1909 -1843
- package/plugin/src/Emulation.luau +172 -0
- package/plugin/src/Phrase.luau +164 -16
- package/plugin/src/Png.luau +8 -4
- package/plugin/src/Serialize.luau +499 -327
- package/plugin/src/Undo.luau +94 -6
- package/plugin/src/handlers/Anim.luau +897 -0
- package/plugin/src/handlers/Assets.luau +587 -352
- package/plugin/src/handlers/Capture.luau +155 -20
- package/plugin/src/handlers/Character.luau +823 -361
- package/plugin/src/handlers/Data.luau +539 -0
- package/plugin/src/handlers/Device.luau +394 -139
- package/plugin/src/handlers/Discover.luau +685 -363
- package/plugin/src/handlers/Geometry.luau +127 -0
- package/plugin/src/handlers/Perf.luau +227 -0
- package/plugin/src/handlers/Scripts.luau +673 -539
- package/plugin/src/handlers/Session.luau +3 -0
- package/plugin/src/handlers/Viewport.luau +268 -0
- package/plugin/src/handlers/World.luau +89 -15
- package/plugin/src/init.server.luau +879 -832
- package/scripts/build-plugin.mjs +20 -0
- package/scripts/check-plugin.mjs +171 -124
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
shape. Emulation persists until it is switched off, which is exactly the kind
|
|
9
9
|
of state that goes wrong quietly.
|
|
10
10
|
|
|
11
|
+
The network conditions Studio is shaping traffic with live here too. They are
|
|
12
|
+
the same kind of state for the same reason: set once, invisible on screen,
|
|
13
|
+
and they stay until something turns them off. A place that "feels laggy" with
|
|
14
|
+
a forgotten 400ms delay on it is exactly the bug this file exists to stop
|
|
15
|
+
people chasing.
|
|
16
|
+
|
|
11
17
|
`StudioDeviceSimulatorService` is undocumented on its return shapes, so what
|
|
12
18
|
is here was read off a live session. Two things are worth knowing:
|
|
13
19
|
|
|
@@ -21,8 +27,172 @@
|
|
|
21
27
|
|
|
22
28
|
local StudioDeviceSimulatorService = game:GetService("StudioDeviceSimulatorService")
|
|
23
29
|
|
|
30
|
+
--[[
|
|
31
|
+
`NetworkSettings` is not a `game` service; it hangs off `settings()`.
|
|
32
|
+
|
|
33
|
+
`game:GetService("NetworkSettings")` throws outright, which is worth writing
|
|
34
|
+
down because every other service in this codebase is reached that way. The
|
|
35
|
+
traffic-shaping properties on it -- delay, jitter, loss, in each direction --
|
|
36
|
+
are PluginSecurity, so they are readable and writable here and nowhere near a
|
|
37
|
+
running game.
|
|
38
|
+
|
|
39
|
+
Guarded: it is reached through a `pcall` at every use rather than captured
|
|
40
|
+
once, because a Studio old enough to lack the properties should cost the
|
|
41
|
+
network feature, not the whole `device` tool.
|
|
42
|
+
]]
|
|
43
|
+
local function networkSettings(): any?
|
|
44
|
+
local ok, service = pcall(function()
|
|
45
|
+
return (settings() :: any):GetService("NetworkSettings")
|
|
46
|
+
end)
|
|
47
|
+
if ok and service ~= nil then
|
|
48
|
+
return service
|
|
49
|
+
end
|
|
50
|
+
return nil
|
|
51
|
+
end
|
|
52
|
+
|
|
24
53
|
local Emulation = {}
|
|
25
54
|
|
|
55
|
+
Emulation.networkSettings = networkSettings
|
|
56
|
+
|
|
57
|
+
--[[
|
|
58
|
+
The six traffic-shaping properties, in the order a person reads them.
|
|
59
|
+
|
|
60
|
+
Inbound is what the client receives and is what a laggy connection feels
|
|
61
|
+
like; outbound is what it sends, which is what makes a player's own actions
|
|
62
|
+
arrive late to everyone else. Both are shaped separately by Studio, so both
|
|
63
|
+
are carried separately here.
|
|
64
|
+
]]
|
|
65
|
+
--[[
|
|
66
|
+
The memory ceiling this session set, if any.
|
|
67
|
+
|
|
68
|
+
Session-local by necessity: see the note in `Emulation.network`. A cap set by
|
|
69
|
+
Studio's own toolbar before the plugin loaded is invisible to this, which is
|
|
70
|
+
the honest failure -- reporting a cap that is not there was the alternative,
|
|
71
|
+
and it was worse.
|
|
72
|
+
]]
|
|
73
|
+
local memoryCap: number? = nil
|
|
74
|
+
|
|
75
|
+
local NETWORK_FIELDS = {
|
|
76
|
+
{ key = "inLatency", property = "InboundNetworkMinDelayMs", scale = 1 },
|
|
77
|
+
{ key = "inJitter", property = "InboundNetworkJitterMs", scale = 1 },
|
|
78
|
+
{ key = "inLoss", property = "InboundNetworkLossPercent", scale = 100 },
|
|
79
|
+
{ key = "outLatency", property = "OutboundNetworkMinDelayMs", scale = 1 },
|
|
80
|
+
{ key = "outJitter", property = "OutboundNetworkJitterMs", scale = 1 },
|
|
81
|
+
{ key = "outLoss", property = "OutboundNetworkLossPercent", scale = 100 },
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
--[[
|
|
85
|
+
What the engine will actually accept, measured rather than assumed.
|
|
86
|
+
|
|
87
|
+
None of this is documented and all three of them bite. Writes outside these
|
|
88
|
+
ranges SUCCEED and are silently clamped, so a preset asking for 2% loss was
|
|
89
|
+
reporting 0.5 back and the only way to notice was to read the value again --
|
|
90
|
+
which is how this was found.
|
|
91
|
+
|
|
92
|
+
* Loss is a FRACTION, despite `LossPercent` in the name: it tops out at 0.5,
|
|
93
|
+
and 0.5 is half the packets, not half a percent. Everything above this
|
|
94
|
+
layer speaks in percent because that is what a person means by "2% loss",
|
|
95
|
+
so the conversion happens at the edges -- `scale` above on the way out, and
|
|
96
|
+
`LOSS_LIMIT` on the way in.
|
|
97
|
+
* Delay and jitter both stop at 1000ms. A second of latency is already past
|
|
98
|
+
anything a real connection does, so this costs nothing but honesty.
|
|
99
|
+
]]
|
|
100
|
+
local LOSS_LIMIT = 0.5
|
|
101
|
+
local DELAY_LIMIT = 1000
|
|
102
|
+
|
|
103
|
+
Emulation.LOSS_LIMIT_PERCENT = LOSS_LIMIT * 100
|
|
104
|
+
Emulation.DELAY_LIMIT = DELAY_LIMIT
|
|
105
|
+
|
|
106
|
+
--[[
|
|
107
|
+
What Studio is currently doing to network traffic.
|
|
108
|
+
|
|
109
|
+
`shaping` is the field callers should branch on: every value being zero is
|
|
110
|
+
the normal, un-emulated state, and reporting six zeroes without saying so
|
|
111
|
+
makes "is anything on" a question the caller has to work out for itself.
|
|
112
|
+
]]
|
|
113
|
+
function Emulation.network(): { [string]: any }
|
|
114
|
+
local service = networkSettings()
|
|
115
|
+
if service == nil then
|
|
116
|
+
return { available = false, shaping = false }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
local state: { [string]: any } = { available = true }
|
|
120
|
+
local shaping = false
|
|
121
|
+
for _, field in NETWORK_FIELDS do
|
|
122
|
+
local ok, value = pcall(function()
|
|
123
|
+
return (service :: any)[field.property]
|
|
124
|
+
end)
|
|
125
|
+
local number = if ok then tonumber(value) or 0 else 0
|
|
126
|
+
--[[
|
|
127
|
+
Scaled into the caller's units, then rounded to two places. These are
|
|
128
|
+
32-bit floats: 2% loss is stored as 0.02 and reads back as
|
|
129
|
+
0.019999999552965164, and reporting that instead of 2 makes a
|
|
130
|
+
perfectly applied setting look like a failed one.
|
|
131
|
+
]]
|
|
132
|
+
number *= field.scale
|
|
133
|
+
state[field.key] = math.round(number * 100) / 100
|
|
134
|
+
if number > 0 then
|
|
135
|
+
shaping = true
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
--[[
|
|
140
|
+
Reported, but deliberately not counted as shaping.
|
|
141
|
+
|
|
142
|
+
`EmulatedTotalMemoryInMB` does not read back 0 when nothing is emulated:
|
|
143
|
+
it answers with the machine's real memory -- measured, 16159 on the
|
|
144
|
+
session this was written against. Treating "greater than zero" as a cap
|
|
145
|
+
therefore declared every untouched Studio to be emulating a low-memory
|
|
146
|
+
device, and `studio_status` told the agent traffic was being degraded on
|
|
147
|
+
a session where nothing had ever been set.
|
|
148
|
+
|
|
149
|
+
There is no read that distinguishes a cap from the true figure, so the
|
|
150
|
+
cap is remembered instead, by the one thing that sets it.
|
|
151
|
+
]]
|
|
152
|
+
local okMemory, memory = pcall(function()
|
|
153
|
+
return (service :: any).EmulatedTotalMemoryInMB
|
|
154
|
+
end)
|
|
155
|
+
if okMemory then
|
|
156
|
+
state.memoryMB = math.floor(tonumber(memory) or 0)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
if memoryCap ~= nil and memoryCap > 0 then
|
|
160
|
+
state.memoryCapMB = memoryCap
|
|
161
|
+
shaping = true
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
state.shaping = shaping
|
|
165
|
+
return state
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
--[[
|
|
169
|
+
Records the memory ceiling this session asked for.
|
|
170
|
+
|
|
171
|
+
Held here rather than in the handler because `network` writes it and
|
|
172
|
+
`Emulation.network` reports it, and a value those two disagree about is worse
|
|
173
|
+
than no value at all. nil or 0 means no cap.
|
|
174
|
+
]]
|
|
175
|
+
function Emulation.setMemoryCap(megabytes: number?)
|
|
176
|
+
memoryCap = if megabytes ~= nil and megabytes > 0 then megabytes else nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
--[[
|
|
180
|
+
The short form for `studio_status`, or nil when traffic is untouched.
|
|
181
|
+
|
|
182
|
+
Separate from the device summary rather than folded into it: a session can be
|
|
183
|
+
shaping traffic without emulating a device, and the device summary returns
|
|
184
|
+
nil in exactly that case.
|
|
185
|
+
]]
|
|
186
|
+
function Emulation.networkSummary(): { [string]: any }?
|
|
187
|
+
local state = Emulation.network()
|
|
188
|
+
if state.shaping ~= true then
|
|
189
|
+
return nil
|
|
190
|
+
end
|
|
191
|
+
state.note = 'Studio is degrading network traffic on purpose. `device op="network" '
|
|
192
|
+
.. 'preset="clear"` restores it.'
|
|
193
|
+
return state
|
|
194
|
+
end
|
|
195
|
+
|
|
26
196
|
--[[
|
|
27
197
|
The resolution as it actually appears, rather than as the panel is specified.
|
|
28
198
|
|
|
@@ -128,6 +298,8 @@ function Emulation.state(): { [string]: any }
|
|
|
128
298
|
state.scalingMode = tostring(scaling)
|
|
129
299
|
end
|
|
130
300
|
|
|
301
|
+
state.network = Emulation.network()
|
|
302
|
+
|
|
131
303
|
return state
|
|
132
304
|
end
|
|
133
305
|
|
package/plugin/src/Phrase.luau
CHANGED
|
@@ -272,9 +272,6 @@ local DESCRIBERS: { [string]: Describer } = {
|
|
|
272
272
|
return "Search the place"
|
|
273
273
|
end,
|
|
274
274
|
|
|
275
|
-
["script.debugSet"] = function()
|
|
276
|
-
return "Set breakpoints"
|
|
277
|
-
end,
|
|
278
275
|
["debug.set"] = function(params)
|
|
279
276
|
local breakpoints = params.breakpoints
|
|
280
277
|
local total = count(breakpoints)
|
|
@@ -481,19 +478,6 @@ local DESCRIBERS: { [string]: Describer } = {
|
|
|
481
478
|
["studio.ping"] = function()
|
|
482
479
|
return "Ping"
|
|
483
480
|
end,
|
|
484
|
-
["studio.transport"] = function(params)
|
|
485
|
-
local mode = params.mode
|
|
486
|
-
return if typeof(mode) == "string" and mode ~= ""
|
|
487
|
-
then string.format("Switch to %s transport", mode)
|
|
488
|
-
else "Check the transport"
|
|
489
|
-
end,
|
|
490
|
-
|
|
491
|
-
--[[
|
|
492
|
-
The one a person actually watches: a live playtest is the point where
|
|
493
|
-
"what is the AI doing" stops being abstract, and a console line reading
|
|
494
|
-
"Input send" says nothing a bystander could act on. One step names the
|
|
495
|
-
key or point; several collapse to a count, same as every other batch op.
|
|
496
|
-
]]
|
|
497
481
|
["input.send"] = function(params)
|
|
498
482
|
local steps = params.steps
|
|
499
483
|
local total = count(steps)
|
|
@@ -535,6 +519,158 @@ local DESCRIBERS: { [string]: Describer } = {
|
|
|
535
519
|
else "Measure the scene"
|
|
536
520
|
end,
|
|
537
521
|
|
|
522
|
+
--[[
|
|
523
|
+
Everything below was added after the first pass over this table, and each
|
|
524
|
+
one spent time showing as the fallback -- "Anim preview", "Data set",
|
|
525
|
+
"Terrain fill". That form is honest but says nothing about the subject,
|
|
526
|
+
which is the whole point of this module: the panel is watched to see WHAT
|
|
527
|
+
an agent is touching, and 26 of 72 operations were declining to say.
|
|
528
|
+
]]
|
|
529
|
+
["anim.read"] = function(params)
|
|
530
|
+
local name = leaf(params.assetId)
|
|
531
|
+
return if name ~= nil then "Read the animation " .. name else "Read an animation"
|
|
532
|
+
end,
|
|
533
|
+
["anim.build"] = function(params)
|
|
534
|
+
local frames = count(params.keyframes)
|
|
535
|
+
return string.format("Build an animation from %s", plural(frames, "keyframe"))
|
|
536
|
+
end,
|
|
537
|
+
["anim.preview"] = function(params)
|
|
538
|
+
local rig = leaf(params.rig) or "a rig"
|
|
539
|
+
if params.op == "stop" then
|
|
540
|
+
return "Clear the pose on " .. rig
|
|
541
|
+
end
|
|
542
|
+
local at = tonumber(params.at)
|
|
543
|
+
return if at ~= nil
|
|
544
|
+
then string.format("Pose %s at %.2fs", rig, at)
|
|
545
|
+
else "Pose " .. rig
|
|
546
|
+
end,
|
|
547
|
+
|
|
548
|
+
["data.list"] = function(params)
|
|
549
|
+
local store = params.store
|
|
550
|
+
if typeof(store) == "string" and store ~= "" then
|
|
551
|
+
return string.format("List the keys in %s", store)
|
|
552
|
+
end
|
|
553
|
+
return "List the data stores"
|
|
554
|
+
end,
|
|
555
|
+
["data.get"] = function(params)
|
|
556
|
+
return string.format("Read %s from %s", tostring(params.key), tostring(params.store))
|
|
557
|
+
end,
|
|
558
|
+
["data.versions"] = function(params)
|
|
559
|
+
return string.format("Read the history of %s", tostring(params.key))
|
|
560
|
+
end,
|
|
561
|
+
["data.set"] = function(params)
|
|
562
|
+
return string.format("SAVE over %s in %s", tostring(params.key), tostring(params.store))
|
|
563
|
+
end,
|
|
564
|
+
["data.remove"] = function(params)
|
|
565
|
+
return string.format("DELETE %s from %s", tostring(params.key), tostring(params.store))
|
|
566
|
+
end,
|
|
567
|
+
|
|
568
|
+
["discover.tags"] = function(params)
|
|
569
|
+
local where = leaf(params.path)
|
|
570
|
+
return if where ~= nil then "List the tags used in " .. where else "List every tag in use"
|
|
571
|
+
end,
|
|
572
|
+
|
|
573
|
+
["viewport.ui"] = function()
|
|
574
|
+
return "Check the interface for layout faults"
|
|
575
|
+
end,
|
|
576
|
+
["viewport.textbounds"] = function(params)
|
|
577
|
+
local name = leaf(params.path)
|
|
578
|
+
return if name ~= nil then "Measure the text in " .. name else "Measure some text"
|
|
579
|
+
end,
|
|
580
|
+
|
|
581
|
+
["perf.audit"] = function()
|
|
582
|
+
return "Look for references that point at nothing"
|
|
583
|
+
end,
|
|
584
|
+
|
|
585
|
+
["assets.audio"] = function(params)
|
|
586
|
+
local keyword = params.keyword
|
|
587
|
+
return if typeof(keyword) == "string" and keyword ~= ""
|
|
588
|
+
then string.format('Search audio for "%s"', keyword)
|
|
589
|
+
else "Search the audio library"
|
|
590
|
+
end,
|
|
591
|
+
["assets.peek"] = function(params)
|
|
592
|
+
return string.format("Look inside asset %s without inserting it", tostring(params.assetId))
|
|
593
|
+
end,
|
|
594
|
+
["assets.bake"] = function(params)
|
|
595
|
+
return "Bake " .. subjectOf(params.paths, "instance")
|
|
596
|
+
end,
|
|
597
|
+
|
|
598
|
+
["character.path"] = function(params)
|
|
599
|
+
local target = leaf(params.toPath)
|
|
600
|
+
return if target ~= nil then "Check the route to " .. target else "Check a route"
|
|
601
|
+
end,
|
|
602
|
+
|
|
603
|
+
["device.network"] = function(params)
|
|
604
|
+
local preset = params.preset
|
|
605
|
+
return if typeof(preset) == "string" and preset ~= ""
|
|
606
|
+
then string.format("Simulate a %s connection", preset)
|
|
607
|
+
else "Shape the network connection"
|
|
608
|
+
end,
|
|
609
|
+
|
|
610
|
+
["geometry.sweep"] = function(params)
|
|
611
|
+
local name = leaf(params.path)
|
|
612
|
+
return if name ~= nil then "Sweep the path of " .. name else "Sweep a motion volume"
|
|
613
|
+
end,
|
|
614
|
+
["geometry.mesh"] = function(params)
|
|
615
|
+
return "Read the geometry of " .. subjectOf(params.paths, "mesh")
|
|
616
|
+
end,
|
|
617
|
+
|
|
618
|
+
["generate.model"] = function(params)
|
|
619
|
+
local prompt = params.prompt
|
|
620
|
+
return if typeof(prompt) == "string" and prompt ~= ""
|
|
621
|
+
then string.format('Generate a model: "%s"', prompt)
|
|
622
|
+
else "Generate a model"
|
|
623
|
+
end,
|
|
624
|
+
["generate.segment"] = function(params)
|
|
625
|
+
local name = leaf(params.path)
|
|
626
|
+
return if name ~= nil then "Cut " .. name .. " into named parts" else "Segment a mesh"
|
|
627
|
+
end,
|
|
628
|
+
|
|
629
|
+
["script.open"] = function(params)
|
|
630
|
+
local name = leaf(params.path)
|
|
631
|
+
return if name ~= nil then "Open " .. name .. " in the editor" else "Open a script"
|
|
632
|
+
end,
|
|
633
|
+
|
|
634
|
+
["terrain.fill"] = function(params)
|
|
635
|
+
--[[
|
|
636
|
+
The material lives inside the shapes, not beside them.
|
|
637
|
+
|
|
638
|
+
Written first against a top-level `material`, which this op has never
|
|
639
|
+
taken, so it always fell through to the bare "Fill terrain" -- seen in
|
|
640
|
+
the panel for a call that laid 50 voxels of sand. The parameters a
|
|
641
|
+
describer reads have to be the ones the handler reads.
|
|
642
|
+
]]
|
|
643
|
+
local shapes = params.shapes
|
|
644
|
+
if typeof(shapes) == "table" and #(shapes :: { any }) > 0 then
|
|
645
|
+
local list = shapes :: { any }
|
|
646
|
+
local first = list[1]
|
|
647
|
+
local material = if typeof(first) == "table" then first.material else nil
|
|
648
|
+
local named = typeof(material) == "string" and material ~= ""
|
|
649
|
+
if #list == 1 then
|
|
650
|
+
return if named
|
|
651
|
+
then string.format("Fill terrain with %s", material)
|
|
652
|
+
else "Fill terrain"
|
|
653
|
+
end
|
|
654
|
+
return if named
|
|
655
|
+
then string.format("Fill %s, starting with %s", plural(#list, "terrain shape"), material)
|
|
656
|
+
else string.format("Fill %s", plural(#list, "terrain shape"))
|
|
657
|
+
end
|
|
658
|
+
return "Fill terrain"
|
|
659
|
+
end,
|
|
660
|
+
["terrain.clear"] = function()
|
|
661
|
+
return "CLEAR terrain"
|
|
662
|
+
end,
|
|
663
|
+
["terrain.replace"] = function(params)
|
|
664
|
+
return string.format(
|
|
665
|
+
"Replace %s terrain with %s",
|
|
666
|
+
tostring(params.from or "one material"),
|
|
667
|
+
tostring(params.to or "another")
|
|
668
|
+
)
|
|
669
|
+
end,
|
|
670
|
+
["terrain.stats"] = function()
|
|
671
|
+
return "Measure the terrain"
|
|
672
|
+
end,
|
|
673
|
+
|
|
538
674
|
["device.list"] = function()
|
|
539
675
|
return "List devices"
|
|
540
676
|
end,
|
|
@@ -576,6 +712,18 @@ local KINDS: { [string]: string } = {
|
|
|
576
712
|
perf = "debug",
|
|
577
713
|
api = "read",
|
|
578
714
|
device = "read",
|
|
715
|
+
--[[
|
|
716
|
+
Absent groups defaulted to "read", which is the wrong way round for a
|
|
717
|
+
default: a missing entry made `terrain.clear` and `data.set` -- one of
|
|
718
|
+
which erases the map and the other of which overwrites a player's save
|
|
719
|
+
with no undo -- show in the panel with the same weight as an inspect.
|
|
720
|
+
A kind is a warning, so the ones that change things are named here
|
|
721
|
+
explicitly.
|
|
722
|
+
]]
|
|
723
|
+
terrain = "write",
|
|
724
|
+
generate = "write",
|
|
725
|
+
anim = "write",
|
|
726
|
+
data = "write",
|
|
579
727
|
}
|
|
580
728
|
|
|
581
729
|
function Phrase.kindOf(op: string): string
|
package/plugin/src/Png.luau
CHANGED
|
@@ -207,10 +207,14 @@ end
|
|
|
207
207
|
--[[
|
|
208
208
|
Nearest-neighbour downscale, RGBA in and RGB out.
|
|
209
209
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
210
|
+
The fallback path. `Capture` scales on the engine side now -- see
|
|
211
|
+
`Capture.resample` -- because this loop is six buffer calls per output pixel
|
|
212
|
+
in Luau and nearest-neighbour is the wrong filter for the job: at the usual
|
|
213
|
+
2x reduction it does not "keep thin edges", it deletes every other row and
|
|
214
|
+
column outright, which is exactly where one-pixel GUI text lives. This stays
|
|
215
|
+
for the cases the engine path cannot take: a Studio without
|
|
216
|
+
`CreateEditableImage`, or a target wider than the 1024 an EditableImage can
|
|
217
|
+
be created at.
|
|
214
218
|
]]
|
|
215
219
|
function Png.downscaleToRgb(rgba: buffer, width: number, height: number, targetWidth: number): (buffer, number, number)
|
|
216
220
|
local scale = math.min(1, targetWidth / width)
|