dominus-cli 2.1.0 → 2.2.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.
- package/CHANGELOG.md +141 -96
- package/README.md +24 -13
- package/dist/install-plugin.js +60 -13
- package/dist/install-plugin.js.map +1 -1
- package/dist/mcp.js +1194 -349
- package/dist/mcp.js.map +1 -1
- package/docs/DOMINUS_2_PLAN.md +12 -6
- package/package.json +5 -3
- package/plugin/Dominus.rbxmx +3674 -0
- package/plugin/src/BridgeConfig.lua +2 -0
- package/plugin/src/ValueCodec.lua +6 -5
- package/plugin/src/WsClient.lua +13 -0
- package/plugin/src/init.server.lua +35 -22
- package/plugin/Dominus.rbxm +0 -0
|
@@ -0,0 +1,3674 @@
|
|
|
1
|
+
<roblox version="4">
|
|
2
|
+
<Item class="Script" referent="0">
|
|
3
|
+
<Properties>
|
|
4
|
+
<string name="Name">Dominus</string>
|
|
5
|
+
<token name="RunContext">0</token>
|
|
6
|
+
<string name="Source"><![CDATA[local HttpService = game:GetService("HttpService")
|
|
7
|
+
|
|
8
|
+
local EmbeddedBridgeToken = require(script.BridgeConfig)
|
|
9
|
+
local CommandRouter = require(script.CommandRouter)
|
|
10
|
+
local Protocol = require(script.Protocol)
|
|
11
|
+
local Watcher = require(script.Watcher)
|
|
12
|
+
local WsClient = require(script.WsClient)
|
|
13
|
+
|
|
14
|
+
local PROTOCOL_VERSION = 2
|
|
15
|
+
local DEFAULT_PORT = 18088
|
|
16
|
+
local MAX_MESSAGE_BYTES = 1024 * 1024
|
|
17
|
+
|
|
18
|
+
local toolbar = plugin:CreateToolbar("Dominus 2")
|
|
19
|
+
local connectButton = toolbar:CreateButton(
|
|
20
|
+
"Dominus 2",
|
|
21
|
+
"Connect this Studio session to the Dominus 2 MCP server",
|
|
22
|
+
"rbxassetid://12974519994"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
local authenticated = false
|
|
26
|
+
local transportOpen = false
|
|
27
|
+
local shouldReconnect = true
|
|
28
|
+
local reconnectScheduled = false
|
|
29
|
+
local reconnectDelay = 1
|
|
30
|
+
local shuttingDown = false
|
|
31
|
+
local connecting = false
|
|
32
|
+
|
|
33
|
+
local function getSetting(name, default)
|
|
34
|
+
local ok, value = pcall(function()
|
|
35
|
+
return plugin:GetSetting(name)
|
|
36
|
+
end)
|
|
37
|
+
if ok and value ~= nil then
|
|
38
|
+
return value
|
|
39
|
+
end
|
|
40
|
+
return default
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
local clientId = getSetting("Dominus2ClientId", nil)
|
|
44
|
+
if type(clientId) ~= "string" then
|
|
45
|
+
clientId = HttpService:GenerateGUID(false)
|
|
46
|
+
pcall(function()
|
|
47
|
+
plugin:SetSetting("Dominus2ClientId", clientId)
|
|
48
|
+
end)
|
|
49
|
+
end
|
|
50
|
+
local bridgeToken = getSetting("Dominus2BridgeToken", nil)
|
|
51
|
+
if type(EmbeddedBridgeToken) == "string" and EmbeddedBridgeToken ~= "__DOMINUS_BRIDGE_TOKEN__" then
|
|
52
|
+
bridgeToken = EmbeddedBridgeToken
|
|
53
|
+
pcall(function()
|
|
54
|
+
plugin:SetSetting("Dominus2BridgeToken", bridgeToken)
|
|
55
|
+
end)
|
|
56
|
+
end
|
|
57
|
+
local port = tonumber(getSetting("Dominus2Port", DEFAULT_PORT)) or DEFAULT_PORT
|
|
58
|
+
|
|
59
|
+
local function log(message)
|
|
60
|
+
print("[Dominus 2] " .. message)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
local function sendEnvelope(message)
|
|
64
|
+
local encodedOk, encoded = pcall(Protocol.encode, message)
|
|
65
|
+
if not encodedOk then
|
|
66
|
+
return false, tostring(encoded)
|
|
67
|
+
end
|
|
68
|
+
if #encoded > MAX_MESSAGE_BYTES then
|
|
69
|
+
return false, "Message exceeds one MiB"
|
|
70
|
+
end
|
|
71
|
+
return WsClient.send(encoded)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
local function sendResponse(id, payload)
|
|
75
|
+
return sendEnvelope({ id = id, type = "studio:response", payload = payload, ts = os.time() })
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
local scheduleReconnect
|
|
79
|
+
|
|
80
|
+
local function stopSession()
|
|
81
|
+
authenticated = false
|
|
82
|
+
transportOpen = false
|
|
83
|
+
Watcher.stop()
|
|
84
|
+
connectButton:SetActive(false)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
local function handleMessage(rawMessage)
|
|
88
|
+
if #rawMessage > MAX_MESSAGE_BYTES then
|
|
89
|
+
warn("[Dominus 2] Rejected an oversized server message")
|
|
90
|
+
WsClient.disconnect()
|
|
91
|
+
return
|
|
92
|
+
end
|
|
93
|
+
local decodedOk, message = pcall(Protocol.decode, rawMessage)
|
|
94
|
+
if not decodedOk or type(message) ~= "table" then
|
|
95
|
+
warn("[Dominus 2] Rejected an invalid server message")
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if message.type == "server:authenticated" then
|
|
100
|
+
local payload = message.payload or {}
|
|
101
|
+
if payload.protocolVersion ~= PROTOCOL_VERSION or type(payload.connectionId) ~= "string" then
|
|
102
|
+
warn("[Dominus 2] Server returned an invalid authentication response")
|
|
103
|
+
WsClient.disconnect()
|
|
104
|
+
return
|
|
105
|
+
end
|
|
106
|
+
if type(payload.token) == "string" then
|
|
107
|
+
bridgeToken = payload.token
|
|
108
|
+
pcall(function()
|
|
109
|
+
plugin:SetSetting("Dominus2BridgeToken", bridgeToken)
|
|
110
|
+
end)
|
|
111
|
+
end
|
|
112
|
+
authenticated = true
|
|
113
|
+
connectButton:SetActive(true)
|
|
114
|
+
log("Authenticated as Studio session " .. payload.connectionId)
|
|
115
|
+
Watcher.start(function(entry)
|
|
116
|
+
if authenticated then
|
|
117
|
+
sendEnvelope({ id = Protocol.generateId(), type = "studio:output", payload = entry, ts = os.time() })
|
|
118
|
+
end
|
|
119
|
+
end)
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if message.type == "server:auth_rejected" then
|
|
124
|
+
warn("[Dominus 2] Authentication failed. Run dominus-install-plugin and restart Studio.")
|
|
125
|
+
WsClient.disconnect()
|
|
126
|
+
return
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
if not authenticated then
|
|
130
|
+
warn("[Dominus 2] Ignored a command before authentication")
|
|
131
|
+
return
|
|
132
|
+
end
|
|
133
|
+
CommandRouter.handle(message, sendResponse)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
local function connect()
|
|
137
|
+
if shuttingDown or transportOpen then
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
if connecting then
|
|
141
|
+
if shouldReconnect then
|
|
142
|
+
scheduleReconnect()
|
|
143
|
+
end
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
connecting = true
|
|
147
|
+
reconnectScheduled = false
|
|
148
|
+
local success = WsClient.connect("ws://127.0.0.1:" .. port, {
|
|
149
|
+
onOpen = function()
|
|
150
|
+
transportOpen = true
|
|
151
|
+
reconnectDelay = 1
|
|
152
|
+
connectButton:SetActive(true)
|
|
153
|
+
local sent, sendErr = sendEnvelope({
|
|
154
|
+
id = Protocol.generateId(),
|
|
155
|
+
type = "studio:hello",
|
|
156
|
+
payload = {
|
|
157
|
+
protocolVersion = PROTOCOL_VERSION,
|
|
158
|
+
clientId = clientId,
|
|
159
|
+
token = bridgeToken,
|
|
160
|
+
studioVersion = version(),
|
|
161
|
+
placeId = game.PlaceId,
|
|
162
|
+
placeName = game.Name,
|
|
163
|
+
},
|
|
164
|
+
ts = os.time(),
|
|
165
|
+
})
|
|
166
|
+
if not sent then
|
|
167
|
+
warn("[Dominus 2] Could not send handshake: " .. tostring(sendErr))
|
|
168
|
+
stopSession()
|
|
169
|
+
WsClient.disconnect()
|
|
170
|
+
end
|
|
171
|
+
end,
|
|
172
|
+
onMessage = function(message)
|
|
173
|
+
task.spawn(handleMessage, message)
|
|
174
|
+
end,
|
|
175
|
+
onClose = function()
|
|
176
|
+
local wasOpen = transportOpen
|
|
177
|
+
stopSession()
|
|
178
|
+
if wasOpen and not shuttingDown then
|
|
179
|
+
log("Studio bridge disconnected")
|
|
180
|
+
end
|
|
181
|
+
if shouldReconnect then
|
|
182
|
+
scheduleReconnect()
|
|
183
|
+
end
|
|
184
|
+
end,
|
|
185
|
+
onError = function(statusCode, errorMessage)
|
|
186
|
+
if transportOpen then
|
|
187
|
+
warn("[Dominus 2] Bridge error " .. tostring(statusCode) .. ": " .. tostring(errorMessage))
|
|
188
|
+
stopSession()
|
|
189
|
+
WsClient.disconnect()
|
|
190
|
+
if shouldReconnect then
|
|
191
|
+
scheduleReconnect()
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end,
|
|
195
|
+
}, 4)
|
|
196
|
+
connecting = false
|
|
197
|
+
if not success then
|
|
198
|
+
stopSession()
|
|
199
|
+
if shouldReconnect then
|
|
200
|
+
scheduleReconnect()
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
scheduleReconnect = function()
|
|
206
|
+
if reconnectScheduled or shuttingDown or not shouldReconnect then
|
|
207
|
+
return
|
|
208
|
+
end
|
|
209
|
+
reconnectScheduled = true
|
|
210
|
+
local delaySeconds = reconnectDelay
|
|
211
|
+
reconnectDelay = math.min(reconnectDelay * 2, 30)
|
|
212
|
+
task.delay(delaySeconds, function()
|
|
213
|
+
reconnectScheduled = false
|
|
214
|
+
if shouldReconnect and not shuttingDown then
|
|
215
|
+
task.spawn(connect)
|
|
216
|
+
end
|
|
217
|
+
end)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
connectButton.Click:Connect(function()
|
|
221
|
+
shouldReconnect = true
|
|
222
|
+
reconnectDelay = 1
|
|
223
|
+
stopSession()
|
|
224
|
+
WsClient.disconnect()
|
|
225
|
+
log("Reconnecting to the Dominus 2 MCP server")
|
|
226
|
+
task.spawn(function()
|
|
227
|
+
while connecting and not shuttingDown do
|
|
228
|
+
task.wait()
|
|
229
|
+
end
|
|
230
|
+
connect()
|
|
231
|
+
end)
|
|
232
|
+
end)
|
|
233
|
+
|
|
234
|
+
task.delay(1, function()
|
|
235
|
+
task.spawn(connect)
|
|
236
|
+
end)
|
|
237
|
+
|
|
238
|
+
plugin.Unloading:Connect(function()
|
|
239
|
+
shuttingDown = true
|
|
240
|
+
shouldReconnect = false
|
|
241
|
+
stopSession()
|
|
242
|
+
WsClient.disconnect()
|
|
243
|
+
end)
|
|
244
|
+
]]></string>
|
|
245
|
+
</Properties>
|
|
246
|
+
<Item class="ModuleScript" referent="1">
|
|
247
|
+
<Properties>
|
|
248
|
+
<string name="Name">BridgeConfig</string>
|
|
249
|
+
<string name="Source"><![CDATA[-- Replaced with a user-specific token by dominus-install-plugin.
|
|
250
|
+
return "__DOMINUS_BRIDGE_TOKEN__"
|
|
251
|
+
]]></string>
|
|
252
|
+
</Properties>
|
|
253
|
+
</Item>
|
|
254
|
+
<Item class="ModuleScript" referent="2">
|
|
255
|
+
<Properties>
|
|
256
|
+
<string name="Name">CommandRouter</string>
|
|
257
|
+
<string name="Source"><![CDATA[local Explorer = require(script.Parent.Explorer)
|
|
258
|
+
local InstanceRegistry = require(script.Parent.InstanceRegistry)
|
|
259
|
+
local Mutation = require(script.Parent.Mutation)
|
|
260
|
+
local Properties = require(script.Parent.Properties)
|
|
261
|
+
local Reflection = require(script.Parent.Reflection)
|
|
262
|
+
local TestRunner = require(script.Parent.TestRunner)
|
|
263
|
+
local UIBuilder = require(script.Parent.UIBuilder)
|
|
264
|
+
local Watcher = require(script.Parent.Watcher)
|
|
265
|
+
|
|
266
|
+
local CommandRouter = {}
|
|
267
|
+
|
|
268
|
+
local handlers = {
|
|
269
|
+
["studio:v2:get_tree"] = function(payload)
|
|
270
|
+
return Explorer.getTreeV2(payload)
|
|
271
|
+
end,
|
|
272
|
+
["studio:v2:inspect"] = function(payload)
|
|
273
|
+
return Properties.inspectV2(payload)
|
|
274
|
+
end,
|
|
275
|
+
["studio:v2:get_selection"] = function()
|
|
276
|
+
local Selection = game:GetService("Selection")
|
|
277
|
+
local selected = {}
|
|
278
|
+
for _, instance in Selection:Get() do
|
|
279
|
+
table.insert(selected, {
|
|
280
|
+
name = instance.Name,
|
|
281
|
+
className = instance.ClassName,
|
|
282
|
+
ref = InstanceRegistry.toRef(instance),
|
|
283
|
+
})
|
|
284
|
+
end
|
|
285
|
+
return { success = true, selection = selected }
|
|
286
|
+
end,
|
|
287
|
+
["studio:v2:read_script"] = function(payload)
|
|
288
|
+
return Explorer.readScriptV2(payload)
|
|
289
|
+
end,
|
|
290
|
+
["studio:v2:update_script"] = function(payload)
|
|
291
|
+
return Explorer.updateScriptV2(payload)
|
|
292
|
+
end,
|
|
293
|
+
["studio:v2:apply"] = function(payload)
|
|
294
|
+
return Mutation.apply(payload)
|
|
295
|
+
end,
|
|
296
|
+
["studio:v2:delete"] = function(payload)
|
|
297
|
+
return Mutation.delete(payload)
|
|
298
|
+
end,
|
|
299
|
+
["studio:v2:build_ui"] = function(payload)
|
|
300
|
+
return UIBuilder.buildV2(payload)
|
|
301
|
+
end,
|
|
302
|
+
["studio:v2:snapshot_ui"] = function(payload)
|
|
303
|
+
return UIBuilder.snapshotV2(payload)
|
|
304
|
+
end,
|
|
305
|
+
["studio:v2:run_test"] = function(payload)
|
|
306
|
+
return TestRunner.executeV2(payload)
|
|
307
|
+
end,
|
|
308
|
+
["studio:v2:get_output"] = function(payload)
|
|
309
|
+
local result = Watcher.getRecentOutput(math.clamp(payload.limit or 100, 1, 500), payload.level)
|
|
310
|
+
result.success = true
|
|
311
|
+
return result
|
|
312
|
+
end,
|
|
313
|
+
["studio:v2:get_reflection"] = function(payload)
|
|
314
|
+
if type(payload.className) ~= "string" then
|
|
315
|
+
return { success = false, error = "className must be a string" }
|
|
316
|
+
end
|
|
317
|
+
return Reflection.getClassInfo(payload.className)
|
|
318
|
+
end,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function CommandRouter.handle(message, sendResponse)
|
|
322
|
+
if type(message) ~= "table" or type(message.id) ~= "string" or #message.id > 128 then
|
|
323
|
+
return false, "Invalid Dominus request envelope"
|
|
324
|
+
end
|
|
325
|
+
if type(message.type) ~= "string" or type(message.payload) ~= "table" then
|
|
326
|
+
return false, "Invalid Dominus request type or payload"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
local handler = handlers[message.type]
|
|
330
|
+
if not handler then
|
|
331
|
+
sendResponse(message.id, {
|
|
332
|
+
success = false,
|
|
333
|
+
error = "Command is not allowed by the Dominus 2 plugin: " .. message.type,
|
|
334
|
+
})
|
|
335
|
+
return true
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
task.spawn(function()
|
|
339
|
+
local ok, result = pcall(handler, message.payload)
|
|
340
|
+
if not ok then
|
|
341
|
+
result = { success = false, error = "Command failed: " .. tostring(result) }
|
|
342
|
+
elseif type(result) ~= "table" then
|
|
343
|
+
result = { success = false, error = "Command returned an invalid result" }
|
|
344
|
+
end
|
|
345
|
+
sendResponse(message.id, result)
|
|
346
|
+
end)
|
|
347
|
+
return true
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
return CommandRouter
|
|
351
|
+
]]></string>
|
|
352
|
+
</Properties>
|
|
353
|
+
</Item>
|
|
354
|
+
<Item class="ModuleScript" referent="3">
|
|
355
|
+
<Properties>
|
|
356
|
+
<string name="Name">Explorer</string>
|
|
357
|
+
<string name="Source"><![CDATA[--[[
|
|
358
|
+
Explorer module: serialize DataModel tree, read/write scripts, search, manage instances
|
|
359
|
+
]]
|
|
360
|
+
|
|
361
|
+
local ScriptEditorService = game:GetService("ScriptEditorService")
|
|
362
|
+
local InstanceRegistry = require(script.Parent.InstanceRegistry)
|
|
363
|
+
|
|
364
|
+
local Explorer = {}
|
|
365
|
+
|
|
366
|
+
local SCRIPT_CLASSES = {
|
|
367
|
+
Script = true,
|
|
368
|
+
LocalScript = true,
|
|
369
|
+
ModuleScript = true,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function Explorer.getPath(instance)
|
|
373
|
+
local parts = {}
|
|
374
|
+
local current = instance
|
|
375
|
+
while current and current ~= game do
|
|
376
|
+
table.insert(parts, 1, current.Name)
|
|
377
|
+
current = current.Parent
|
|
378
|
+
end
|
|
379
|
+
return table.concat(parts, ".")
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
function Explorer.getRef(instance)
|
|
383
|
+
return InstanceRegistry.toRef(instance)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
function Explorer.resolveTarget(target)
|
|
387
|
+
return InstanceRegistry.resolve(target)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
function Explorer.resolveInstance(path)
|
|
391
|
+
local parts = string.split(path, ".")
|
|
392
|
+
local current = game
|
|
393
|
+
for _, part in parts do
|
|
394
|
+
local child = current:FindFirstChild(part)
|
|
395
|
+
if not child then
|
|
396
|
+
return nil
|
|
397
|
+
end
|
|
398
|
+
current = child
|
|
399
|
+
end
|
|
400
|
+
return current
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
function Explorer.getTree(rootPath, maxDepth)
|
|
404
|
+
maxDepth = maxDepth or 3
|
|
405
|
+
|
|
406
|
+
local root
|
|
407
|
+
if rootPath and rootPath ~= "" then
|
|
408
|
+
root = Explorer.resolveInstance(rootPath)
|
|
409
|
+
if not root then
|
|
410
|
+
return {}
|
|
411
|
+
end
|
|
412
|
+
else
|
|
413
|
+
root = game
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
local function serialize(instance, depth)
|
|
417
|
+
if depth > maxDepth then
|
|
418
|
+
return nil
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
local node = {
|
|
422
|
+
name = instance.Name,
|
|
423
|
+
className = instance.ClassName,
|
|
424
|
+
path = Explorer.getPath(instance),
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
local children = {}
|
|
428
|
+
for _, child in instance:GetChildren() do
|
|
429
|
+
local childNode = serialize(child, depth + 1)
|
|
430
|
+
if childNode then
|
|
431
|
+
table.insert(children, childNode)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
if #children > 0 then
|
|
436
|
+
node.children = children
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
return node
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
if root == game then
|
|
443
|
+
local topLevel = {}
|
|
444
|
+
for _, service in game:GetChildren() do
|
|
445
|
+
local ok, node = pcall(serialize, service, 1)
|
|
446
|
+
if ok and node then
|
|
447
|
+
table.insert(topLevel, node)
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
return topLevel
|
|
451
|
+
else
|
|
452
|
+
local node = serialize(root, 0)
|
|
453
|
+
return node and { node } or {}
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
function Explorer.getScriptSource(path)
|
|
458
|
+
local instance = Explorer.resolveInstance(path)
|
|
459
|
+
if not instance then
|
|
460
|
+
return { success = false, error = "Instance not found: " .. path }
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
if not SCRIPT_CLASSES[instance.ClassName] then
|
|
464
|
+
return { success = false, error = "Not a script: " .. path }
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
local ok, source = pcall(function()
|
|
468
|
+
return ScriptEditorService:GetEditorSource(instance)
|
|
469
|
+
end)
|
|
470
|
+
|
|
471
|
+
if not ok then
|
|
472
|
+
-- Fallback to .Source property
|
|
473
|
+
ok, source = pcall(function()
|
|
474
|
+
return instance.Source
|
|
475
|
+
end)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
if not ok then
|
|
479
|
+
return { success = false, error = "Could not read source: " .. tostring(source) }
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
local lines = select(2, source:gsub("\n", "\n")) + 1
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
path = path,
|
|
486
|
+
className = instance.ClassName,
|
|
487
|
+
source = source,
|
|
488
|
+
lineCount = lines,
|
|
489
|
+
}
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
function Explorer.setScriptSource(path, newSource)
|
|
493
|
+
local instance = Explorer.resolveInstance(path)
|
|
494
|
+
if not instance then
|
|
495
|
+
return { success = false, error = "Instance not found: " .. path }
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
if not SCRIPT_CLASSES[instance.ClassName] then
|
|
499
|
+
return { success = false, error = "Not a script: " .. path }
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
local ok, err = pcall(function()
|
|
503
|
+
ScriptEditorService:UpdateSourceAsync(instance, function()
|
|
504
|
+
return newSource
|
|
505
|
+
end)
|
|
506
|
+
end)
|
|
507
|
+
|
|
508
|
+
if not ok then
|
|
509
|
+
return { success = false, error = "Failed to update source: " .. tostring(err) }
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
return { success = true }
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
function Explorer.insertInstance(className, parentPath, name, properties)
|
|
516
|
+
local parent = Explorer.resolveInstance(parentPath)
|
|
517
|
+
if not parent then
|
|
518
|
+
return { success = false, error = "Parent not found: " .. parentPath }
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
local ok, instance = pcall(function()
|
|
522
|
+
local obj = Instance.new(className)
|
|
523
|
+
obj.Name = name
|
|
524
|
+
|
|
525
|
+
if properties then
|
|
526
|
+
for key, value in properties do
|
|
527
|
+
pcall(function()
|
|
528
|
+
obj[key] = value
|
|
529
|
+
end)
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
obj.Parent = parent
|
|
534
|
+
return obj
|
|
535
|
+
end)
|
|
536
|
+
|
|
537
|
+
if not ok then
|
|
538
|
+
return { success = false, error = "Failed to create instance: " .. tostring(instance) }
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
return { success = true, path = Explorer.getPath(instance) }
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
function Explorer.deleteInstance(path)
|
|
545
|
+
local instance = Explorer.resolveInstance(path)
|
|
546
|
+
if not instance then
|
|
547
|
+
return { success = false, error = "Instance not found: " .. path }
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
local ok, err = pcall(function()
|
|
551
|
+
instance:Destroy()
|
|
552
|
+
end)
|
|
553
|
+
|
|
554
|
+
if not ok then
|
|
555
|
+
return { success = false, error = "Failed to delete: " .. tostring(err) }
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
return { success = true }
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
function Explorer.searchScripts(query, maxResults)
|
|
562
|
+
maxResults = maxResults or 20
|
|
563
|
+
local results = {}
|
|
564
|
+
local queryLower = string.lower(query)
|
|
565
|
+
|
|
566
|
+
local function searchIn(instance)
|
|
567
|
+
if #results >= maxResults then
|
|
568
|
+
return
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
if SCRIPT_CLASSES[instance.ClassName] then
|
|
572
|
+
local ok, source = pcall(function()
|
|
573
|
+
return ScriptEditorService:GetEditorSource(instance)
|
|
574
|
+
end)
|
|
575
|
+
|
|
576
|
+
if not ok then
|
|
577
|
+
ok, source = pcall(function()
|
|
578
|
+
return instance.Source
|
|
579
|
+
end)
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
if ok and source then
|
|
583
|
+
local matches = {}
|
|
584
|
+
local lineNum = 0
|
|
585
|
+
for line in source:gmatch("[^\n]+") do
|
|
586
|
+
lineNum = lineNum + 1
|
|
587
|
+
if string.find(string.lower(line), queryLower, 1, true) then
|
|
588
|
+
table.insert(matches, { line = lineNum, text = line })
|
|
589
|
+
if #matches >= 5 then
|
|
590
|
+
break
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
if #matches > 0 then
|
|
596
|
+
table.insert(results, {
|
|
597
|
+
path = Explorer.getPath(instance),
|
|
598
|
+
className = instance.ClassName,
|
|
599
|
+
matches = matches,
|
|
600
|
+
})
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
for _, child in instance:GetChildren() do
|
|
606
|
+
if #results >= maxResults then
|
|
607
|
+
return
|
|
608
|
+
end
|
|
609
|
+
pcall(searchIn, child)
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
pcall(searchIn, game)
|
|
614
|
+
|
|
615
|
+
return { results = results }
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
function Explorer.findReplaceScripts(spec)
|
|
619
|
+
local find = spec.find
|
|
620
|
+
local replace = spec.replace
|
|
621
|
+
local usePattern = spec.usePattern or false
|
|
622
|
+
local dryRun = spec.dryRun or false
|
|
623
|
+
|
|
624
|
+
if not find or find == "" then
|
|
625
|
+
return { success = false, error = "Missing 'find' parameter" }
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
local root = game
|
|
629
|
+
if spec.root and spec.root ~= "" then
|
|
630
|
+
root = Explorer.resolveInstance(spec.root)
|
|
631
|
+
if not root then
|
|
632
|
+
local ok, svc = pcall(function()
|
|
633
|
+
return game:GetService(spec.root)
|
|
634
|
+
end)
|
|
635
|
+
if ok then
|
|
636
|
+
root = svc
|
|
637
|
+
else
|
|
638
|
+
return { success = false, error = "Root not found: " .. spec.root }
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
|
644
|
+
local recording = nil
|
|
645
|
+
if not dryRun then
|
|
646
|
+
recording = ChangeHistoryService:TryBeginRecording("Dominus: Find/replace in scripts")
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
local modified = {}
|
|
650
|
+
local totalMatches = 0
|
|
651
|
+
local totalFiles = 0
|
|
652
|
+
|
|
653
|
+
local function processScript(instance)
|
|
654
|
+
if not SCRIPT_CLASSES[instance.ClassName] then
|
|
655
|
+
return
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
local ok, source = pcall(function()
|
|
659
|
+
return ScriptEditorService:GetEditorSource(instance)
|
|
660
|
+
end)
|
|
661
|
+
if not ok then
|
|
662
|
+
ok, source = pcall(function()
|
|
663
|
+
return instance.Source
|
|
664
|
+
end)
|
|
665
|
+
end
|
|
666
|
+
if not ok or not source then
|
|
667
|
+
return
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
local count = 0
|
|
671
|
+
if usePattern then
|
|
672
|
+
_, count = source:gsub(find, "")
|
|
673
|
+
else
|
|
674
|
+
_, count = source:gsub(find, "", nil)
|
|
675
|
+
-- plain text: count occurrences with plain find
|
|
676
|
+
count = 0
|
|
677
|
+
local startPos = 1
|
|
678
|
+
while true do
|
|
679
|
+
local foundPos = source:find(find, startPos, true)
|
|
680
|
+
if not foundPos then
|
|
681
|
+
break
|
|
682
|
+
end
|
|
683
|
+
count = count + 1
|
|
684
|
+
startPos = foundPos + #find
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
if count > 0 then
|
|
689
|
+
totalMatches = totalMatches + count
|
|
690
|
+
totalFiles = totalFiles + 1
|
|
691
|
+
|
|
692
|
+
local path = Explorer.getPath(instance)
|
|
693
|
+
table.insert(modified, { path = path, matches = count })
|
|
694
|
+
|
|
695
|
+
if not dryRun then
|
|
696
|
+
local newSource
|
|
697
|
+
if usePattern then
|
|
698
|
+
newSource = source:gsub(find, replace)
|
|
699
|
+
else
|
|
700
|
+
newSource =
|
|
701
|
+
source:gsub(find:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1"), replace:gsub("%%", "%%%%"))
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
pcall(function()
|
|
705
|
+
ScriptEditorService:UpdateSourceAsync(instance, function()
|
|
706
|
+
return newSource
|
|
707
|
+
end)
|
|
708
|
+
end)
|
|
709
|
+
end
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
local function walk(instance)
|
|
714
|
+
processScript(instance)
|
|
715
|
+
for _, child in instance:GetChildren() do
|
|
716
|
+
pcall(walk, child)
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
pcall(walk, root)
|
|
721
|
+
|
|
722
|
+
if recording then
|
|
723
|
+
if totalFiles > 0 then
|
|
724
|
+
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
|
|
725
|
+
else
|
|
726
|
+
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
return {
|
|
731
|
+
success = true,
|
|
732
|
+
modified = modified,
|
|
733
|
+
totalMatches = totalMatches,
|
|
734
|
+
totalFiles = totalFiles,
|
|
735
|
+
}
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
local function sourceHash(source)
|
|
739
|
+
local hash = 5381
|
|
740
|
+
for index = 1, #source do
|
|
741
|
+
hash = (hash * 33 + string.byte(source, index)) % 4294967296
|
|
742
|
+
end
|
|
743
|
+
return string.format("%d:%08x", #source, hash)
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
function Explorer.getTreeV2(spec)
|
|
747
|
+
local root = game
|
|
748
|
+
if spec.root then
|
|
749
|
+
local resolved, err = InstanceRegistry.resolve(spec.root)
|
|
750
|
+
if not resolved then
|
|
751
|
+
return { success = false, error = err }
|
|
752
|
+
end
|
|
753
|
+
root = resolved
|
|
754
|
+
end
|
|
755
|
+
local maxDepth = math.clamp(spec.maxDepth or 3, 0, 12)
|
|
756
|
+
local maxNodes = math.clamp(spec.maxNodes or 1000, 1, 5000)
|
|
757
|
+
local nodeCount = 0
|
|
758
|
+
local truncated = false
|
|
759
|
+
|
|
760
|
+
local function serialize(instance, depth)
|
|
761
|
+
if depth > maxDepth or nodeCount >= maxNodes then
|
|
762
|
+
truncated = true
|
|
763
|
+
return nil
|
|
764
|
+
end
|
|
765
|
+
nodeCount += 1
|
|
766
|
+
local node = {
|
|
767
|
+
name = instance.Name,
|
|
768
|
+
className = instance.ClassName,
|
|
769
|
+
ref = InstanceRegistry.toRef(instance),
|
|
770
|
+
}
|
|
771
|
+
local children = instance:GetChildren()
|
|
772
|
+
table.sort(children, function(a, b)
|
|
773
|
+
if a.Name == b.Name then
|
|
774
|
+
return a.ClassName < b.ClassName
|
|
775
|
+
end
|
|
776
|
+
return a.Name < b.Name
|
|
777
|
+
end)
|
|
778
|
+
for _, child in children do
|
|
779
|
+
local childNode = serialize(child, depth + 1)
|
|
780
|
+
if childNode then
|
|
781
|
+
node.children = node.children or {}
|
|
782
|
+
table.insert(node.children, childNode)
|
|
783
|
+
end
|
|
784
|
+
if nodeCount >= maxNodes then
|
|
785
|
+
break
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
return node
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
if root == game then
|
|
792
|
+
local roots = {}
|
|
793
|
+
local services = game:GetChildren()
|
|
794
|
+
table.sort(services, function(a, b)
|
|
795
|
+
return a.Name < b.Name
|
|
796
|
+
end)
|
|
797
|
+
for _, service in services do
|
|
798
|
+
local node = serialize(service, 0)
|
|
799
|
+
if node then
|
|
800
|
+
table.insert(roots, node)
|
|
801
|
+
end
|
|
802
|
+
if nodeCount >= maxNodes then
|
|
803
|
+
break
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
return { success = true, roots = roots, nodeCount = nodeCount, truncated = truncated }
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
return { success = true, roots = { serialize(root, 0) }, nodeCount = nodeCount, truncated = truncated }
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
function Explorer.readScriptV2(spec)
|
|
813
|
+
local instance, err = InstanceRegistry.resolve(spec.target)
|
|
814
|
+
if not instance then
|
|
815
|
+
return { success = false, error = err }
|
|
816
|
+
end
|
|
817
|
+
if not SCRIPT_CLASSES[instance.ClassName] then
|
|
818
|
+
return { success = false, error = "Target is not a LuaSourceContainer" }
|
|
819
|
+
end
|
|
820
|
+
local ok, source = pcall(function()
|
|
821
|
+
return ScriptEditorService:GetEditorSource(instance)
|
|
822
|
+
end)
|
|
823
|
+
if not ok then
|
|
824
|
+
return { success = false, error = "Could not read script source: " .. tostring(source) }
|
|
825
|
+
end
|
|
826
|
+
return {
|
|
827
|
+
success = true,
|
|
828
|
+
target = InstanceRegistry.toRef(instance),
|
|
829
|
+
className = instance.ClassName,
|
|
830
|
+
source = source,
|
|
831
|
+
lineCount = select(2, source:gsub("\n", "\n")) + 1,
|
|
832
|
+
revision = sourceHash(source),
|
|
833
|
+
}
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
function Explorer.updateScriptV2(spec)
|
|
837
|
+
local instance, err = InstanceRegistry.resolve(spec.target)
|
|
838
|
+
if not instance then
|
|
839
|
+
return { success = false, error = err }
|
|
840
|
+
end
|
|
841
|
+
if not SCRIPT_CLASSES[instance.ClassName] then
|
|
842
|
+
return { success = false, error = "Target is not a LuaSourceContainer" }
|
|
843
|
+
end
|
|
844
|
+
if type(spec.source) ~= "string" then
|
|
845
|
+
return { success = false, error = "source must be a string" }
|
|
846
|
+
end
|
|
847
|
+
if #spec.source > 750000 then
|
|
848
|
+
return { success = false, error = "source exceeds 750,000 characters" }
|
|
849
|
+
end
|
|
850
|
+
if type(spec.expectedRevision) ~= "string" then
|
|
851
|
+
return { success = false, error = "expectedRevision is required; call studio_read_script first" }
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
local conflictRevision = nil
|
|
855
|
+
local ok, updateErr = pcall(function()
|
|
856
|
+
ScriptEditorService:UpdateSourceAsync(instance, function(oldSource)
|
|
857
|
+
local currentRevision = sourceHash(oldSource)
|
|
858
|
+
if currentRevision ~= spec.expectedRevision then
|
|
859
|
+
conflictRevision = currentRevision
|
|
860
|
+
return nil
|
|
861
|
+
end
|
|
862
|
+
return spec.source
|
|
863
|
+
end)
|
|
864
|
+
end)
|
|
865
|
+
if conflictRevision then
|
|
866
|
+
return {
|
|
867
|
+
success = false,
|
|
868
|
+
conflict = true,
|
|
869
|
+
error = "Script changed since it was read",
|
|
870
|
+
currentRevision = conflictRevision,
|
|
871
|
+
}
|
|
872
|
+
end
|
|
873
|
+
if not ok then
|
|
874
|
+
return { success = false, error = "Script update failed: " .. tostring(updateErr) }
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
local readback = Explorer.readScriptV2({ target = InstanceRegistry.toRef(instance) })
|
|
878
|
+
return {
|
|
879
|
+
success = true,
|
|
880
|
+
target = readback.target,
|
|
881
|
+
revision = readback.revision,
|
|
882
|
+
lineCount = readback.lineCount,
|
|
883
|
+
}
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
return Explorer
|
|
887
|
+
]]></string>
|
|
888
|
+
</Properties>
|
|
889
|
+
</Item>
|
|
890
|
+
<Item class="ModuleScript" referent="4">
|
|
891
|
+
<Properties>
|
|
892
|
+
<string name="Name">InstanceRegistry</string>
|
|
893
|
+
<string name="Source"><![CDATA[local HttpService = game:GetService("HttpService")
|
|
894
|
+
|
|
895
|
+
local InstanceRegistry = {}
|
|
896
|
+
|
|
897
|
+
local instanceToId = setmetatable({}, { __mode = "k" })
|
|
898
|
+
local idToInstance = {}
|
|
899
|
+
|
|
900
|
+
local function register(instance)
|
|
901
|
+
local existing = instanceToId[instance]
|
|
902
|
+
if existing then
|
|
903
|
+
return existing
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
local id = HttpService:GenerateGUID(false)
|
|
907
|
+
instanceToId[instance] = id
|
|
908
|
+
idToInstance[id] = instance
|
|
909
|
+
instance.Destroying:Connect(function()
|
|
910
|
+
if idToInstance[id] == instance then
|
|
911
|
+
idToInstance[id] = nil
|
|
912
|
+
end
|
|
913
|
+
instanceToId[instance] = nil
|
|
914
|
+
end)
|
|
915
|
+
return id
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
function InstanceRegistry.getPathSegments(instance)
|
|
919
|
+
local segments = {}
|
|
920
|
+
local current = instance
|
|
921
|
+
while current and current ~= game do
|
|
922
|
+
table.insert(segments, 1, current.Name)
|
|
923
|
+
current = current.Parent
|
|
924
|
+
end
|
|
925
|
+
return segments
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
function InstanceRegistry.getDisplayPath(instance)
|
|
929
|
+
local path = "game"
|
|
930
|
+
for _, segment in InstanceRegistry.getPathSegments(instance) do
|
|
931
|
+
local escaped = segment:gsub("\\", "\\\\"):gsub('"', '\\"')
|
|
932
|
+
path ..= '["' .. escaped .. '"]'
|
|
933
|
+
end
|
|
934
|
+
return path
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
function InstanceRegistry.toRef(instance)
|
|
938
|
+
return {
|
|
939
|
+
instanceId = register(instance),
|
|
940
|
+
pathSegments = InstanceRegistry.getPathSegments(instance),
|
|
941
|
+
displayPath = InstanceRegistry.getDisplayPath(instance),
|
|
942
|
+
}
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
local function resolveSegments(segments)
|
|
946
|
+
if type(segments) ~= "table" or #segments == 0 then
|
|
947
|
+
return nil, "pathSegments must contain at least one name"
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
local current = game
|
|
951
|
+
for index, segment in segments do
|
|
952
|
+
if type(segment) ~= "string" then
|
|
953
|
+
return nil, "pathSegments[" .. index .. "] must be a string"
|
|
954
|
+
end
|
|
955
|
+
local match = nil
|
|
956
|
+
local count = 0
|
|
957
|
+
for _, child in current:GetChildren() do
|
|
958
|
+
if child.Name == segment then
|
|
959
|
+
match = child
|
|
960
|
+
count += 1
|
|
961
|
+
end
|
|
962
|
+
end
|
|
963
|
+
if count == 0 and current == game then
|
|
964
|
+
local ok, service = pcall(function()
|
|
965
|
+
return game:GetService(segment)
|
|
966
|
+
end)
|
|
967
|
+
if ok then
|
|
968
|
+
match = service
|
|
969
|
+
count = 1
|
|
970
|
+
end
|
|
971
|
+
end
|
|
972
|
+
if count == 0 then
|
|
973
|
+
return nil, "Instance path does not exist at segment: " .. segment
|
|
974
|
+
end
|
|
975
|
+
if count > 1 then
|
|
976
|
+
return nil, "Instance path is ambiguous at duplicate sibling name: " .. segment
|
|
977
|
+
end
|
|
978
|
+
current = match
|
|
979
|
+
end
|
|
980
|
+
return current
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
function InstanceRegistry.resolve(target)
|
|
984
|
+
if typeof(target) == "Instance" then
|
|
985
|
+
return target
|
|
986
|
+
end
|
|
987
|
+
if type(target) ~= "table" then
|
|
988
|
+
return nil, "Target must be an instance reference"
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
if type(target.instanceId) == "string" then
|
|
992
|
+
local instance = idToInstance[target.instanceId]
|
|
993
|
+
if instance then
|
|
994
|
+
return instance
|
|
995
|
+
end
|
|
996
|
+
return nil, "Instance reference is stale: " .. target.instanceId
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
if target.pathSegments then
|
|
1000
|
+
local instance, err = resolveSegments(target.pathSegments)
|
|
1001
|
+
if instance then
|
|
1002
|
+
register(instance)
|
|
1003
|
+
end
|
|
1004
|
+
return instance, err
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
return nil, "Target requires instanceId or pathSegments"
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
function InstanceRegistry.register(instance)
|
|
1011
|
+
return register(instance)
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
return InstanceRegistry
|
|
1015
|
+
]]></string>
|
|
1016
|
+
</Properties>
|
|
1017
|
+
</Item>
|
|
1018
|
+
<Item class="ModuleScript" referent="5">
|
|
1019
|
+
<Properties>
|
|
1020
|
+
<string name="Name">Mutation</string>
|
|
1021
|
+
<string name="Source"><![CDATA[local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
|
1022
|
+
|
|
1023
|
+
local InstanceRegistry = require(script.Parent.InstanceRegistry)
|
|
1024
|
+
local ValueCodec = require(script.Parent.ValueCodec)
|
|
1025
|
+
|
|
1026
|
+
local Mutation = {}
|
|
1027
|
+
local MAX_OPERATIONS = 100
|
|
1028
|
+
local MAX_PROPERTIES = 100
|
|
1029
|
+
|
|
1030
|
+
local function resolve(target, label)
|
|
1031
|
+
local instance, err = InstanceRegistry.resolve(target)
|
|
1032
|
+
if not instance then
|
|
1033
|
+
error(label .. ": " .. tostring(err))
|
|
1034
|
+
end
|
|
1035
|
+
return instance
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
local function setProperties(instance, properties)
|
|
1039
|
+
assert(type(properties) == "table", "properties must be an object")
|
|
1040
|
+
local count = 0
|
|
1041
|
+
local applied = {}
|
|
1042
|
+
for propertyName, value in properties do
|
|
1043
|
+
count += 1
|
|
1044
|
+
if count > MAX_PROPERTIES then
|
|
1045
|
+
error("Too many properties in one operation")
|
|
1046
|
+
end
|
|
1047
|
+
applied[propertyName] = ValueCodec.setProperty(instance, propertyName, value)
|
|
1048
|
+
end
|
|
1049
|
+
return applied
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
function Mutation.apply(spec)
|
|
1053
|
+
local operations = spec.operations
|
|
1054
|
+
if type(operations) ~= "table" or #operations == 0 then
|
|
1055
|
+
return { success = false, error = "operations must contain at least one mutation" }
|
|
1056
|
+
end
|
|
1057
|
+
if #operations > MAX_OPERATIONS then
|
|
1058
|
+
return { success = false, error = "A mutation batch is limited to " .. MAX_OPERATIONS .. " operations" }
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
local recording = ChangeHistoryService:TryBeginRecording("Dominus 2: Apply mutation batch")
|
|
1062
|
+
if not recording then
|
|
1063
|
+
return { success = false, error = "Another plugin recording is already active" }
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
local results = {}
|
|
1067
|
+
local ok, err = pcall(function()
|
|
1068
|
+
for index, operation in operations do
|
|
1069
|
+
assert(type(operation) == "table", "Operation " .. index .. " must be an object")
|
|
1070
|
+
if operation.op == "setProperties" then
|
|
1071
|
+
local target = resolve(operation.target, "Operation " .. index .. " target")
|
|
1072
|
+
table.insert(results, {
|
|
1073
|
+
op = operation.op,
|
|
1074
|
+
target = InstanceRegistry.toRef(target),
|
|
1075
|
+
properties = setProperties(target, operation.properties),
|
|
1076
|
+
})
|
|
1077
|
+
elseif operation.op == "create" then
|
|
1078
|
+
local parent = resolve(operation.parent, "Operation " .. index .. " parent")
|
|
1079
|
+
assert(type(operation.className) == "string", "create.className must be a string")
|
|
1080
|
+
assert(type(operation.name) == "string", "create.name must be a string")
|
|
1081
|
+
local instance = Instance.new(operation.className)
|
|
1082
|
+
instance.Name = operation.name
|
|
1083
|
+
if operation.properties then
|
|
1084
|
+
setProperties(instance, operation.properties)
|
|
1085
|
+
end
|
|
1086
|
+
instance.Parent = parent
|
|
1087
|
+
table.insert(results, { op = operation.op, created = InstanceRegistry.toRef(instance) })
|
|
1088
|
+
elseif operation.op == "move" then
|
|
1089
|
+
local target = resolve(operation.target, "Operation " .. index .. " target")
|
|
1090
|
+
local parent = resolve(operation.parent, "Operation " .. index .. " parent")
|
|
1091
|
+
assert(target ~= game and target.Parent ~= game, "Services cannot be moved")
|
|
1092
|
+
target.Parent = parent
|
|
1093
|
+
table.insert(results, { op = operation.op, target = InstanceRegistry.toRef(target) })
|
|
1094
|
+
else
|
|
1095
|
+
error("Unsupported mutation operation: " .. tostring(operation.op))
|
|
1096
|
+
end
|
|
1097
|
+
end
|
|
1098
|
+
end)
|
|
1099
|
+
|
|
1100
|
+
if not ok then
|
|
1101
|
+
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
|
|
1102
|
+
return { success = false, error = tostring(err), rolledBack = true }
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
|
|
1106
|
+
return { success = true, operationCount = #results, results = results }
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
function Mutation.delete(spec)
|
|
1110
|
+
if spec.confirm ~= true then
|
|
1111
|
+
return { success = false, error = "confirm must be true" }
|
|
1112
|
+
end
|
|
1113
|
+
if type(spec.targets) ~= "table" or #spec.targets == 0 then
|
|
1114
|
+
return { success = false, error = "targets must contain at least one instance reference" }
|
|
1115
|
+
end
|
|
1116
|
+
if #spec.targets > 50 then
|
|
1117
|
+
return { success = false, error = "At most 50 instances can be deleted at once" }
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
local targets = {}
|
|
1121
|
+
local seen = {}
|
|
1122
|
+
for index, targetRef in spec.targets do
|
|
1123
|
+
local target = resolve(targetRef, "Target " .. index)
|
|
1124
|
+
assert(target ~= game and target.Parent ~= game, "Services cannot be deleted")
|
|
1125
|
+
local id = InstanceRegistry.register(target)
|
|
1126
|
+
if not seen[id] then
|
|
1127
|
+
seen[id] = true
|
|
1128
|
+
table.insert(targets, target)
|
|
1129
|
+
end
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
local recording = ChangeHistoryService:TryBeginRecording("Dominus 2: Delete instances")
|
|
1133
|
+
if not recording then
|
|
1134
|
+
return { success = false, error = "Another plugin recording is already active" }
|
|
1135
|
+
end
|
|
1136
|
+
local deleted = {}
|
|
1137
|
+
local ok, err = pcall(function()
|
|
1138
|
+
for _, target in targets do
|
|
1139
|
+
table.insert(deleted, {
|
|
1140
|
+
name = target.Name,
|
|
1141
|
+
className = target.ClassName,
|
|
1142
|
+
pathSegments = InstanceRegistry.getPathSegments(target),
|
|
1143
|
+
})
|
|
1144
|
+
target:Destroy()
|
|
1145
|
+
end
|
|
1146
|
+
end)
|
|
1147
|
+
if not ok then
|
|
1148
|
+
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
|
|
1149
|
+
return { success = false, error = tostring(err), rolledBack = true }
|
|
1150
|
+
end
|
|
1151
|
+
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
|
|
1152
|
+
return { success = true, deleted = deleted }
|
|
1153
|
+
end
|
|
1154
|
+
|
|
1155
|
+
return Mutation
|
|
1156
|
+
]]></string>
|
|
1157
|
+
</Properties>
|
|
1158
|
+
</Item>
|
|
1159
|
+
<Item class="ModuleScript" referent="6">
|
|
1160
|
+
<Properties>
|
|
1161
|
+
<string name="Name">Properties</string>
|
|
1162
|
+
<string name="Source"><![CDATA[--[[
|
|
1163
|
+
Properties module: read/write instance properties
|
|
1164
|
+
Uses ReflectionService to determine expected types for smart coercion.
|
|
1165
|
+
]]
|
|
1166
|
+
|
|
1167
|
+
local Explorer = require(script.Parent.Explorer)
|
|
1168
|
+
local InstanceRegistry = require(script.Parent.InstanceRegistry)
|
|
1169
|
+
local Reflection = require(script.Parent.Reflection)
|
|
1170
|
+
local ValueCodec = require(script.Parent.ValueCodec)
|
|
1171
|
+
local ReflectionService = game:GetService("ReflectionService")
|
|
1172
|
+
|
|
1173
|
+
local Properties = {}
|
|
1174
|
+
|
|
1175
|
+
local IGNORED_PROPERTIES = {
|
|
1176
|
+
Parent = true,
|
|
1177
|
+
ClassName = true,
|
|
1178
|
+
Archivable = true,
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
-- Properties that are read-only / computed / redundant — strip in compact mode
|
|
1182
|
+
local COMPACT_SKIP = {
|
|
1183
|
+
-- Read-only computed
|
|
1184
|
+
AbsolutePosition = true,
|
|
1185
|
+
AbsoluteSize = true,
|
|
1186
|
+
AbsoluteRotation = true,
|
|
1187
|
+
AbsoluteCanvasSize = true,
|
|
1188
|
+
TextBounds = true,
|
|
1189
|
+
TextFits = true,
|
|
1190
|
+
IsLoaded = true,
|
|
1191
|
+
ContentText = true,
|
|
1192
|
+
LocalizedText = true,
|
|
1193
|
+
GuiState = true,
|
|
1194
|
+
-- Deprecated / redundant (BrickColor duplicates Color3, Font duplicates FontFace)
|
|
1195
|
+
BackgroundColor = true,
|
|
1196
|
+
BorderColor = true,
|
|
1197
|
+
TextColor = true,
|
|
1198
|
+
Font = true,
|
|
1199
|
+
FontSize = true,
|
|
1200
|
+
-- Internal / security
|
|
1201
|
+
Capabilities = true,
|
|
1202
|
+
Sandboxed = true,
|
|
1203
|
+
archivable = true,
|
|
1204
|
+
className = true,
|
|
1205
|
+
Localize = true,
|
|
1206
|
+
-- Selection navigation (almost always nil/default)
|
|
1207
|
+
NextSelectionDown = true,
|
|
1208
|
+
NextSelectionLeft = true,
|
|
1209
|
+
NextSelectionRight = true,
|
|
1210
|
+
NextSelectionUp = true,
|
|
1211
|
+
SelectionBehaviorDown = true,
|
|
1212
|
+
SelectionBehaviorLeft = true,
|
|
1213
|
+
SelectionBehaviorRight = true,
|
|
1214
|
+
SelectionBehaviorUp = true,
|
|
1215
|
+
SelectionGroup = true,
|
|
1216
|
+
SelectionOrder = true,
|
|
1217
|
+
SelectionImageObject = true,
|
|
1218
|
+
-- Rarely useful defaults
|
|
1219
|
+
Draggable = true,
|
|
1220
|
+
InputSink = true,
|
|
1221
|
+
RootLocalizationTable = true,
|
|
1222
|
+
AutoLocalize = true,
|
|
1223
|
+
OpenTypeFeatures = true,
|
|
1224
|
+
OpenTypeFeaturesError = true,
|
|
1225
|
+
Interactable = true,
|
|
1226
|
+
Style = true,
|
|
1227
|
+
BorderMode = true,
|
|
1228
|
+
SizeConstraint = true,
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
-- Default values that are not worth reporting in compact mode
|
|
1232
|
+
local COMPACT_DEFAULT_VALUES = {
|
|
1233
|
+
["0"] = true,
|
|
1234
|
+
["1"] = true,
|
|
1235
|
+
["false"] = true,
|
|
1236
|
+
["nil"] = true,
|
|
1237
|
+
[""] = true,
|
|
1238
|
+
["Enum.AutomaticSize.None"] = true,
|
|
1239
|
+
["Enum.TextTruncate.None"] = true,
|
|
1240
|
+
["Enum.TextDirection.Auto"] = true,
|
|
1241
|
+
["Enum.ResamplerMode.Default"] = true,
|
|
1242
|
+
["Enum.ScaleType.Stretch"] = true,
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
-- Cache property type maps per class to avoid repeated reflection calls
|
|
1246
|
+
local typeCache = {}
|
|
1247
|
+
|
|
1248
|
+
local function getPropertyTypeMap(className)
|
|
1249
|
+
if typeCache[className] then
|
|
1250
|
+
return typeCache[className]
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1253
|
+
local typeMap = {}
|
|
1254
|
+
local ok, props = pcall(function()
|
|
1255
|
+
return ReflectionService:GetPropertiesOfClass(className)
|
|
1256
|
+
end)
|
|
1257
|
+
if ok and props then
|
|
1258
|
+
for _, prop in props do
|
|
1259
|
+
if prop.Type then
|
|
1260
|
+
typeMap[prop.Name] = tostring(prop.Type)
|
|
1261
|
+
end
|
|
1262
|
+
end
|
|
1263
|
+
end
|
|
1264
|
+
typeCache[className] = typeMap
|
|
1265
|
+
return typeMap
|
|
1266
|
+
end
|
|
1267
|
+
|
|
1268
|
+
function Properties.get(path, compact)
|
|
1269
|
+
local instance = Explorer.resolveInstance(path)
|
|
1270
|
+
if not instance then
|
|
1271
|
+
return { success = false, error = "Instance not found: " .. path }
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1274
|
+
local properties = {}
|
|
1275
|
+
|
|
1276
|
+
local ok, apiData = pcall(function()
|
|
1277
|
+
local info = {}
|
|
1278
|
+
local propData = ReflectionService:GetPropertiesOfClass(instance.ClassName)
|
|
1279
|
+
for _, prop in propData do
|
|
1280
|
+
if not IGNORED_PROPERTIES[prop.Name] then
|
|
1281
|
+
if compact and COMPACT_SKIP[prop.Name] then
|
|
1282
|
+
continue
|
|
1283
|
+
end
|
|
1284
|
+
local valueOk, value = pcall(function()
|
|
1285
|
+
return instance[prop.Name]
|
|
1286
|
+
end)
|
|
1287
|
+
if valueOk then
|
|
1288
|
+
local valueStr = tostring(value)
|
|
1289
|
+
if compact and valueStr == "nil" then
|
|
1290
|
+
continue
|
|
1291
|
+
end
|
|
1292
|
+
table.insert(info, {
|
|
1293
|
+
name = prop.Name,
|
|
1294
|
+
value = valueStr,
|
|
1295
|
+
type = prop.Type and tostring(prop.Type) or typeof(value),
|
|
1296
|
+
category = prop.Display and prop.Display.Category or "Other",
|
|
1297
|
+
})
|
|
1298
|
+
end
|
|
1299
|
+
end
|
|
1300
|
+
end
|
|
1301
|
+
return info
|
|
1302
|
+
end)
|
|
1303
|
+
|
|
1304
|
+
if ok then
|
|
1305
|
+
properties = apiData
|
|
1306
|
+
else
|
|
1307
|
+
local commonProps =
|
|
1308
|
+
{ "Name", "Position", "Size", "Color", "Material", "Transparency", "Anchored", "CanCollide" }
|
|
1309
|
+
for _, propName in commonProps do
|
|
1310
|
+
local valOk, val = pcall(function()
|
|
1311
|
+
return instance[propName]
|
|
1312
|
+
end)
|
|
1313
|
+
if valOk and val ~= nil then
|
|
1314
|
+
table.insert(properties, {
|
|
1315
|
+
name = propName,
|
|
1316
|
+
value = tostring(val),
|
|
1317
|
+
type = typeof(val),
|
|
1318
|
+
})
|
|
1319
|
+
end
|
|
1320
|
+
end
|
|
1321
|
+
end
|
|
1322
|
+
|
|
1323
|
+
return { properties = properties }
|
|
1324
|
+
end
|
|
1325
|
+
|
|
1326
|
+
function Properties.getDescendants(path, compact)
|
|
1327
|
+
local rootInstance = Explorer.resolveInstance(path)
|
|
1328
|
+
if not rootInstance then
|
|
1329
|
+
return { success = false, error = "Instance not found: " .. path }
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
-- In compact mode, skip noisy/default properties to reduce payload size
|
|
1333
|
+
local shouldSkip = compact
|
|
1334
|
+
and function(propName, valueStr)
|
|
1335
|
+
if COMPACT_SKIP[propName] then
|
|
1336
|
+
return true
|
|
1337
|
+
end
|
|
1338
|
+
-- Skip nil-valued properties
|
|
1339
|
+
if valueStr == "nil" then
|
|
1340
|
+
return true
|
|
1341
|
+
end
|
|
1342
|
+
return false
|
|
1343
|
+
end
|
|
1344
|
+
or function()
|
|
1345
|
+
return false
|
|
1346
|
+
end
|
|
1347
|
+
|
|
1348
|
+
-- Extra compact filter: skip properties that are clearly defaults and non-informative
|
|
1349
|
+
local isBoringDefault = compact
|
|
1350
|
+
and function(propName, valueStr)
|
|
1351
|
+
-- These specific properties at default values are noise
|
|
1352
|
+
local boringAtDefault = {
|
|
1353
|
+
Active = "false",
|
|
1354
|
+
Selectable = "false",
|
|
1355
|
+
ClipsDescendants = "false",
|
|
1356
|
+
TextWrap = "false",
|
|
1357
|
+
TextWrapped = "false",
|
|
1358
|
+
RichText = "false",
|
|
1359
|
+
TextScaled = "false",
|
|
1360
|
+
Rotation = "0",
|
|
1361
|
+
LayoutOrder = "0",
|
|
1362
|
+
MaxVisibleGraphemes = "-1",
|
|
1363
|
+
SliceScale = "1",
|
|
1364
|
+
LineHeight = "1",
|
|
1365
|
+
BorderSizePixel = "1",
|
|
1366
|
+
Visible = "true",
|
|
1367
|
+
}
|
|
1368
|
+
return boringAtDefault[propName] == valueStr
|
|
1369
|
+
end
|
|
1370
|
+
or function()
|
|
1371
|
+
return false
|
|
1372
|
+
end
|
|
1373
|
+
|
|
1374
|
+
local function getProps(instance)
|
|
1375
|
+
local properties = {}
|
|
1376
|
+
|
|
1377
|
+
local ok, apiData = pcall(function()
|
|
1378
|
+
local info = {}
|
|
1379
|
+
local propData = ReflectionService:GetPropertiesOfClass(instance.ClassName)
|
|
1380
|
+
for _, prop in propData do
|
|
1381
|
+
if not IGNORED_PROPERTIES[prop.Name] and not shouldSkip(prop.Name, "") then
|
|
1382
|
+
local valueOk, value = pcall(function()
|
|
1383
|
+
return instance[prop.Name]
|
|
1384
|
+
end)
|
|
1385
|
+
if valueOk then
|
|
1386
|
+
local valueStr = tostring(value)
|
|
1387
|
+
if not shouldSkip(prop.Name, valueStr) and not isBoringDefault(prop.Name, valueStr) then
|
|
1388
|
+
table.insert(info, {
|
|
1389
|
+
name = prop.Name,
|
|
1390
|
+
value = valueStr,
|
|
1391
|
+
type = prop.Type and tostring(prop.Type) or typeof(value),
|
|
1392
|
+
category = prop.Display and prop.Display.Category or "Other",
|
|
1393
|
+
})
|
|
1394
|
+
end
|
|
1395
|
+
end
|
|
1396
|
+
end
|
|
1397
|
+
end
|
|
1398
|
+
return info
|
|
1399
|
+
end)
|
|
1400
|
+
|
|
1401
|
+
if ok then
|
|
1402
|
+
properties = apiData
|
|
1403
|
+
else
|
|
1404
|
+
local commonProps =
|
|
1405
|
+
{ "Name", "Position", "Size", "Color", "Material", "Transparency", "Anchored", "CanCollide" }
|
|
1406
|
+
for _, propName in commonProps do
|
|
1407
|
+
local valOk, val = pcall(function()
|
|
1408
|
+
return instance[propName]
|
|
1409
|
+
end)
|
|
1410
|
+
if valOk and val ~= nil then
|
|
1411
|
+
table.insert(properties, {
|
|
1412
|
+
name = propName,
|
|
1413
|
+
value = tostring(val),
|
|
1414
|
+
type = typeof(val),
|
|
1415
|
+
})
|
|
1416
|
+
end
|
|
1417
|
+
end
|
|
1418
|
+
end
|
|
1419
|
+
|
|
1420
|
+
return properties
|
|
1421
|
+
end
|
|
1422
|
+
|
|
1423
|
+
local results = {}
|
|
1424
|
+
|
|
1425
|
+
-- Include the root instance itself
|
|
1426
|
+
table.insert(results, {
|
|
1427
|
+
name = rootInstance.Name,
|
|
1428
|
+
className = rootInstance.ClassName,
|
|
1429
|
+
path = Explorer.getPath(rootInstance),
|
|
1430
|
+
properties = getProps(rootInstance),
|
|
1431
|
+
})
|
|
1432
|
+
|
|
1433
|
+
for _, descendant in rootInstance:GetDescendants() do
|
|
1434
|
+
table.insert(results, {
|
|
1435
|
+
name = descendant.Name,
|
|
1436
|
+
className = descendant.ClassName,
|
|
1437
|
+
path = Explorer.getPath(descendant),
|
|
1438
|
+
properties = getProps(descendant),
|
|
1439
|
+
})
|
|
1440
|
+
end
|
|
1441
|
+
|
|
1442
|
+
return { success = true, descendants = results }
|
|
1443
|
+
end
|
|
1444
|
+
|
|
1445
|
+
--[[
|
|
1446
|
+
Smart type coercion: converts JSON-friendly values into Roblox types.
|
|
1447
|
+
Uses the expected property type from ReflectionService when available.
|
|
1448
|
+
]]
|
|
1449
|
+
local function coerceValue(instance, key, value)
|
|
1450
|
+
local expectedType = nil
|
|
1451
|
+
local typeMap = getPropertyTypeMap(instance.ClassName)
|
|
1452
|
+
expectedType = typeMap[key]
|
|
1453
|
+
|
|
1454
|
+
-- If we don't know the expected type, try reading the current value
|
|
1455
|
+
if not expectedType then
|
|
1456
|
+
local curOk, curVal = pcall(function()
|
|
1457
|
+
return instance[key]
|
|
1458
|
+
end)
|
|
1459
|
+
if curOk and curVal ~= nil then
|
|
1460
|
+
expectedType = typeof(curVal)
|
|
1461
|
+
end
|
|
1462
|
+
end
|
|
1463
|
+
|
|
1464
|
+
-- ═══════════════════════════════════════════
|
|
1465
|
+
-- UDim2 (GUI Size, Position, etc.)
|
|
1466
|
+
-- ═══════════════════════════════════════════
|
|
1467
|
+
if expectedType == "UDim2" then
|
|
1468
|
+
if type(value) == "table" then
|
|
1469
|
+
-- {xScale, xOffset, yScale, yOffset} or named keys
|
|
1470
|
+
if value.XScale or value.xScale or value.X then
|
|
1471
|
+
return UDim2.new(
|
|
1472
|
+
value.XScale or value.xScale or value.X and value.X.Scale or 0,
|
|
1473
|
+
value.XOffset or value.xOffset or value.X and value.X.Offset or 0,
|
|
1474
|
+
value.YScale or value.yScale or value.Y and value.Y.Scale or 0,
|
|
1475
|
+
value.YOffset or value.yOffset or value.Y and value.Y.Offset or 0
|
|
1476
|
+
)
|
|
1477
|
+
end
|
|
1478
|
+
-- {X = {Scale, Offset}, Y = {Scale, Offset}}
|
|
1479
|
+
if value.X and type(value.X) == "table" then
|
|
1480
|
+
return UDim2.new(
|
|
1481
|
+
value.X.Scale or value.X[1] or 0,
|
|
1482
|
+
value.X.Offset or value.X[2] or 0,
|
|
1483
|
+
value.Y and (value.Y.Scale or value.Y[1]) or 0,
|
|
1484
|
+
value.Y and (value.Y.Offset or value.Y[2]) or 0
|
|
1485
|
+
)
|
|
1486
|
+
end
|
|
1487
|
+
-- Array: {xScale, xOffset, yScale, yOffset}
|
|
1488
|
+
if value[1] ~= nil then
|
|
1489
|
+
return UDim2.new(value[1] or 0, value[2] or 0, value[3] or 0, value[4] or 0)
|
|
1490
|
+
end
|
|
1491
|
+
-- fromScale shorthand: {scale_x, scale_y} when only 2 elements
|
|
1492
|
+
return UDim2.new(0, 0, 0, 0)
|
|
1493
|
+
end
|
|
1494
|
+
if type(value) == "string" then
|
|
1495
|
+
-- "0.5, 0, 0.5, 0" format
|
|
1496
|
+
local a, b, c, d = value:match("([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)")
|
|
1497
|
+
if a then
|
|
1498
|
+
return UDim2.new(tonumber(a), tonumber(b), tonumber(c), tonumber(d))
|
|
1499
|
+
end
|
|
1500
|
+
end
|
|
1501
|
+
end
|
|
1502
|
+
|
|
1503
|
+
-- ═══════════════════════════════════════════
|
|
1504
|
+
-- UDim
|
|
1505
|
+
-- ═══════════════════════════════════════════
|
|
1506
|
+
if expectedType == "UDim" then
|
|
1507
|
+
if type(value) == "table" then
|
|
1508
|
+
return UDim.new(value.Scale or value[1] or 0, value.Offset or value[2] or 0)
|
|
1509
|
+
end
|
|
1510
|
+
if type(value) == "number" then
|
|
1511
|
+
return UDim.new(value, 0)
|
|
1512
|
+
end
|
|
1513
|
+
end
|
|
1514
|
+
|
|
1515
|
+
-- ═══════════════════════════════════════════
|
|
1516
|
+
-- Vector3
|
|
1517
|
+
-- ═══════════════════════════════════════════
|
|
1518
|
+
if expectedType == "Vector3" then
|
|
1519
|
+
if type(value) == "table" then
|
|
1520
|
+
return Vector3.new(
|
|
1521
|
+
value.X or value.x or value[1] or 0,
|
|
1522
|
+
value.Y or value.y or value[2] or 0,
|
|
1523
|
+
value.Z or value.z or value[3] or 0
|
|
1524
|
+
)
|
|
1525
|
+
end
|
|
1526
|
+
end
|
|
1527
|
+
|
|
1528
|
+
-- ═══════════════════════════════════════════
|
|
1529
|
+
-- Vector2
|
|
1530
|
+
-- ═══════════════════════════════════════════
|
|
1531
|
+
if expectedType == "Vector2" then
|
|
1532
|
+
if type(value) == "table" then
|
|
1533
|
+
return Vector2.new(value.X or value.x or value[1] or 0, value.Y or value.y or value[2] or 0)
|
|
1534
|
+
end
|
|
1535
|
+
end
|
|
1536
|
+
|
|
1537
|
+
-- ═══════════════════════════════════════════
|
|
1538
|
+
-- Color3
|
|
1539
|
+
-- ═══════════════════════════════════════════
|
|
1540
|
+
if expectedType == "Color3" then
|
|
1541
|
+
if type(value) == "string" then
|
|
1542
|
+
if value:sub(1, 1) == "#" then
|
|
1543
|
+
return Color3.fromHex(value)
|
|
1544
|
+
end
|
|
1545
|
+
if value:match("^rgb") then
|
|
1546
|
+
local r, g, b = value:match("(%d+)%D+(%d+)%D+(%d+)")
|
|
1547
|
+
if r then
|
|
1548
|
+
return Color3.fromRGB(tonumber(r), tonumber(g), tonumber(b))
|
|
1549
|
+
end
|
|
1550
|
+
end
|
|
1551
|
+
local ok, bc = pcall(BrickColor.new, value)
|
|
1552
|
+
if ok then
|
|
1553
|
+
return bc.Color
|
|
1554
|
+
end
|
|
1555
|
+
end
|
|
1556
|
+
if type(value) == "table" then
|
|
1557
|
+
-- {R, G, B} 0-1 or {r, g, b}
|
|
1558
|
+
if value.R or value.r then
|
|
1559
|
+
return Color3.new(value.R or value.r or 0, value.G or value.g or 0, value.B or value.b or 0)
|
|
1560
|
+
end
|
|
1561
|
+
-- {red, green, blue} 0-255
|
|
1562
|
+
if value.red or value.Red then
|
|
1563
|
+
return Color3.fromRGB(
|
|
1564
|
+
value.red or value.Red or 0,
|
|
1565
|
+
value.green or value.Green or 0,
|
|
1566
|
+
value.blue or value.Blue or 0
|
|
1567
|
+
)
|
|
1568
|
+
end
|
|
1569
|
+
-- Array [r, g, b]
|
|
1570
|
+
if value[1] then
|
|
1571
|
+
if value[1] > 1 or value[2] > 1 or value[3] > 1 then
|
|
1572
|
+
return Color3.fromRGB(value[1], value[2], value[3])
|
|
1573
|
+
end
|
|
1574
|
+
return Color3.new(value[1], value[2], value[3])
|
|
1575
|
+
end
|
|
1576
|
+
end
|
|
1577
|
+
end
|
|
1578
|
+
|
|
1579
|
+
-- ═══════════════════════════════════════════
|
|
1580
|
+
-- BrickColor
|
|
1581
|
+
-- ═══════════════════════════════════════════
|
|
1582
|
+
if expectedType == "BrickColor" then
|
|
1583
|
+
if type(value) == "string" then
|
|
1584
|
+
return BrickColor.new(value)
|
|
1585
|
+
end
|
|
1586
|
+
if type(value) == "number" then
|
|
1587
|
+
return BrickColor.new(value)
|
|
1588
|
+
end
|
|
1589
|
+
end
|
|
1590
|
+
|
|
1591
|
+
-- ═══════════════════════════════════════════
|
|
1592
|
+
-- CFrame
|
|
1593
|
+
-- ═══════════════════════════════════════════
|
|
1594
|
+
if expectedType == "CFrame" then
|
|
1595
|
+
if type(value) == "table" then
|
|
1596
|
+
if value.Position or value.position then
|
|
1597
|
+
local pos = value.Position or value.position
|
|
1598
|
+
local cf = CFrame.new(
|
|
1599
|
+
pos.x or pos.X or pos[1] or 0,
|
|
1600
|
+
pos.y or pos.Y or pos[2] or 0,
|
|
1601
|
+
pos.z or pos.Z or pos[3] or 0
|
|
1602
|
+
)
|
|
1603
|
+
if value.Rotation or value.rotation then
|
|
1604
|
+
local rot = value.Rotation or value.rotation
|
|
1605
|
+
cf = cf
|
|
1606
|
+
* CFrame.Angles(
|
|
1607
|
+
math.rad(rot.x or rot.X or rot[1] or 0),
|
|
1608
|
+
math.rad(rot.y or rot.Y or rot[2] or 0),
|
|
1609
|
+
math.rad(rot.z or rot.Z or rot[3] or 0)
|
|
1610
|
+
)
|
|
1611
|
+
end
|
|
1612
|
+
return cf
|
|
1613
|
+
end
|
|
1614
|
+
-- Simple {x, y, z}
|
|
1615
|
+
if value[1] then
|
|
1616
|
+
return CFrame.new(value[1], value[2] or 0, value[3] or 0)
|
|
1617
|
+
end
|
|
1618
|
+
end
|
|
1619
|
+
end
|
|
1620
|
+
|
|
1621
|
+
-- ═══════════════════════════════════════════
|
|
1622
|
+
-- Rect (for GUI ImageRectOffset, etc.)
|
|
1623
|
+
-- ═══════════════════════════════════════════
|
|
1624
|
+
if expectedType == "Rect" then
|
|
1625
|
+
if type(value) == "table" then
|
|
1626
|
+
return Rect.new(
|
|
1627
|
+
value[1] or value.Min and value.Min[1] or 0,
|
|
1628
|
+
value[2] or value.Min and value.Min[2] or 0,
|
|
1629
|
+
value[3] or value.Max and value.Max[1] or 0,
|
|
1630
|
+
value[4] or value.Max and value.Max[2] or 0
|
|
1631
|
+
)
|
|
1632
|
+
end
|
|
1633
|
+
end
|
|
1634
|
+
|
|
1635
|
+
-- ═══════════════════════════════════════════
|
|
1636
|
+
-- NumberRange
|
|
1637
|
+
-- ═══════════════════════════════════════════
|
|
1638
|
+
if expectedType == "NumberRange" then
|
|
1639
|
+
if type(value) == "table" then
|
|
1640
|
+
return NumberRange.new(value.Min or value[1] or 0, value.Max or value[2] or 1)
|
|
1641
|
+
end
|
|
1642
|
+
if type(value) == "number" then
|
|
1643
|
+
return NumberRange.new(value)
|
|
1644
|
+
end
|
|
1645
|
+
end
|
|
1646
|
+
|
|
1647
|
+
-- ═══════════════════════════════════════════
|
|
1648
|
+
-- NumberSequence (for transparency gradients, etc.)
|
|
1649
|
+
-- ═══════════════════════════════════════════
|
|
1650
|
+
if expectedType == "NumberSequence" then
|
|
1651
|
+
if type(value) == "table" then
|
|
1652
|
+
if value[1] and type(value[1]) == "table" then
|
|
1653
|
+
local keypoints = {}
|
|
1654
|
+
for _, kp in value do
|
|
1655
|
+
table.insert(
|
|
1656
|
+
keypoints,
|
|
1657
|
+
NumberSequenceKeypoint.new(
|
|
1658
|
+
kp.Time or kp[1] or 0,
|
|
1659
|
+
kp.Value or kp[2] or 0,
|
|
1660
|
+
kp.Envelope or kp[3] or 0
|
|
1661
|
+
)
|
|
1662
|
+
)
|
|
1663
|
+
end
|
|
1664
|
+
return NumberSequence.new(keypoints)
|
|
1665
|
+
end
|
|
1666
|
+
if #value == 2 and type(value[1]) == "number" then
|
|
1667
|
+
return NumberSequence.new(value[1], value[2])
|
|
1668
|
+
end
|
|
1669
|
+
end
|
|
1670
|
+
if type(value) == "number" then
|
|
1671
|
+
return NumberSequence.new(value)
|
|
1672
|
+
end
|
|
1673
|
+
end
|
|
1674
|
+
|
|
1675
|
+
-- ═══════════════════════════════════════════
|
|
1676
|
+
-- ColorSequence
|
|
1677
|
+
-- ═══════════════════════════════════════════
|
|
1678
|
+
if expectedType == "ColorSequence" then
|
|
1679
|
+
if type(value) == "table" then
|
|
1680
|
+
if value[1] and type(value[1]) == "table" then
|
|
1681
|
+
local keypoints = {}
|
|
1682
|
+
for _, kp in value do
|
|
1683
|
+
local color = Color3.new(1, 1, 1)
|
|
1684
|
+
if kp.Color then
|
|
1685
|
+
if type(kp.Color) == "string" and kp.Color:sub(1, 1) == "#" then
|
|
1686
|
+
color = Color3.fromHex(kp.Color)
|
|
1687
|
+
elseif type(kp.Color) == "table" then
|
|
1688
|
+
color = Color3.new(kp.Color[1] or 0, kp.Color[2] or 0, kp.Color[3] or 0)
|
|
1689
|
+
end
|
|
1690
|
+
end
|
|
1691
|
+
table.insert(keypoints, ColorSequenceKeypoint.new(kp.Time or kp[1] or 0, color))
|
|
1692
|
+
end
|
|
1693
|
+
return ColorSequence.new(keypoints)
|
|
1694
|
+
end
|
|
1695
|
+
end
|
|
1696
|
+
end
|
|
1697
|
+
|
|
1698
|
+
-- ═══════════════════════════════════════════
|
|
1699
|
+
-- Font
|
|
1700
|
+
-- ═══════════════════════════════════════════
|
|
1701
|
+
if expectedType == "Font" then
|
|
1702
|
+
if type(value) == "table" then
|
|
1703
|
+
local family = value.Family or value.family or "rbxasset://fonts/families/SourceSansPro.json"
|
|
1704
|
+
local weight = Enum.FontWeight.Regular
|
|
1705
|
+
local style = Enum.FontStyle.Normal
|
|
1706
|
+
if value.Weight or value.weight then
|
|
1707
|
+
pcall(function()
|
|
1708
|
+
weight = Enum.FontWeight[value.Weight or value.weight]
|
|
1709
|
+
end)
|
|
1710
|
+
end
|
|
1711
|
+
if value.Style or value.style then
|
|
1712
|
+
pcall(function()
|
|
1713
|
+
style = Enum.FontStyle[value.Style or value.style]
|
|
1714
|
+
end)
|
|
1715
|
+
end
|
|
1716
|
+
return Font.new(family, weight, style)
|
|
1717
|
+
end
|
|
1718
|
+
if type(value) == "string" then
|
|
1719
|
+
return Font.new(value)
|
|
1720
|
+
end
|
|
1721
|
+
end
|
|
1722
|
+
|
|
1723
|
+
-- ═══════════════════════════════════════════
|
|
1724
|
+
-- EnumItem (any enum property)
|
|
1725
|
+
-- ═══════════════════════════════════════════
|
|
1726
|
+
if type(value) == "string" then
|
|
1727
|
+
local currentVal = nil
|
|
1728
|
+
pcall(function()
|
|
1729
|
+
currentVal = instance[key]
|
|
1730
|
+
end)
|
|
1731
|
+
if typeof(currentVal) == "EnumItem" then
|
|
1732
|
+
local enumType = tostring(currentVal.EnumType)
|
|
1733
|
+
local enumOk, enumVal = pcall(function()
|
|
1734
|
+
return Enum[enumType][value]
|
|
1735
|
+
end)
|
|
1736
|
+
if enumOk then
|
|
1737
|
+
return enumVal
|
|
1738
|
+
end
|
|
1739
|
+
end
|
|
1740
|
+
end
|
|
1741
|
+
|
|
1742
|
+
-- ═══════════════════════════════════════════
|
|
1743
|
+
-- Fallback: type-agnostic guesses (no expectedType known)
|
|
1744
|
+
-- ═══════════════════════════════════════════
|
|
1745
|
+
if type(value) == "table" and not expectedType then
|
|
1746
|
+
-- UDim2 guess: has XScale/YScale keys
|
|
1747
|
+
if value.XScale or value.xScale then
|
|
1748
|
+
return UDim2.new(
|
|
1749
|
+
value.XScale or value.xScale or 0,
|
|
1750
|
+
value.XOffset or value.xOffset or 0,
|
|
1751
|
+
value.YScale or value.yScale or 0,
|
|
1752
|
+
value.YOffset or value.yOffset or 0
|
|
1753
|
+
)
|
|
1754
|
+
end
|
|
1755
|
+
-- Vector3 guess: 3 number elements or X/Y/Z keys
|
|
1756
|
+
if (value.X or value.x) and (value.Y or value.y) and (value.Z or value.z) then
|
|
1757
|
+
return Vector3.new(value.X or value.x, value.Y or value.y, value.Z or value.z)
|
|
1758
|
+
end
|
|
1759
|
+
if value[1] and value[2] and value[3] and not value[4] then
|
|
1760
|
+
return Vector3.new(value[1], value[2], value[3])
|
|
1761
|
+
end
|
|
1762
|
+
-- Color3 guess: R/G/B keys
|
|
1763
|
+
if value.R or value.r then
|
|
1764
|
+
return Color3.new(value.R or value.r or 0, value.G or value.g or 0, value.B or value.b or 0)
|
|
1765
|
+
end
|
|
1766
|
+
-- Vector2 guess: 2 number elements
|
|
1767
|
+
if value[1] and value[2] and not value[3] then
|
|
1768
|
+
return Vector2.new(value[1], value[2])
|
|
1769
|
+
end
|
|
1770
|
+
end
|
|
1771
|
+
|
|
1772
|
+
-- String-based color on color properties (fallback)
|
|
1773
|
+
if type(value) == "string" and (key == "Color" or key == "Color3" or key:match("Color$")) then
|
|
1774
|
+
if value:sub(1, 1) == "#" then
|
|
1775
|
+
return Color3.fromHex(value)
|
|
1776
|
+
end
|
|
1777
|
+
local ok, bc = pcall(BrickColor.new, value)
|
|
1778
|
+
if ok then
|
|
1779
|
+
return bc.Color
|
|
1780
|
+
end
|
|
1781
|
+
end
|
|
1782
|
+
|
|
1783
|
+
if type(value) == "string" and key == "BrickColor" then
|
|
1784
|
+
return BrickColor.new(value)
|
|
1785
|
+
end
|
|
1786
|
+
|
|
1787
|
+
return value
|
|
1788
|
+
end
|
|
1789
|
+
|
|
1790
|
+
function Properties.set(path, propertiesToSet)
|
|
1791
|
+
local instance = Explorer.resolveInstance(path)
|
|
1792
|
+
if not instance then
|
|
1793
|
+
return { success = false, error = "Instance not found: " .. path }
|
|
1794
|
+
end
|
|
1795
|
+
|
|
1796
|
+
local errors = {}
|
|
1797
|
+
local set = {}
|
|
1798
|
+
|
|
1799
|
+
for key, value in propertiesToSet do
|
|
1800
|
+
local coerced = coerceValue(instance, key, value)
|
|
1801
|
+
local ok, err = pcall(function()
|
|
1802
|
+
instance[key] = coerced
|
|
1803
|
+
end)
|
|
1804
|
+
if ok then
|
|
1805
|
+
table.insert(set, key)
|
|
1806
|
+
else
|
|
1807
|
+
table.insert(errors, key .. ": " .. tostring(err))
|
|
1808
|
+
end
|
|
1809
|
+
end
|
|
1810
|
+
|
|
1811
|
+
if #errors > 0 then
|
|
1812
|
+
return { success = false, error = table.concat(errors, "; "), set = set }
|
|
1813
|
+
end
|
|
1814
|
+
|
|
1815
|
+
return { success = true, set = set }
|
|
1816
|
+
end
|
|
1817
|
+
|
|
1818
|
+
function Properties.bulkSet(spec)
|
|
1819
|
+
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
|
1820
|
+
local recording = ChangeHistoryService:TryBeginRecording("Dominus: Bulk set properties")
|
|
1821
|
+
|
|
1822
|
+
local modified = 0
|
|
1823
|
+
local errors = {}
|
|
1824
|
+
|
|
1825
|
+
if spec.targets and type(spec.targets) == "table" then
|
|
1826
|
+
-- Explicit mode: array of {path, properties}
|
|
1827
|
+
for _, target in ipairs(spec.targets) do
|
|
1828
|
+
local instance = Explorer.resolveInstance(target.path)
|
|
1829
|
+
if not instance then
|
|
1830
|
+
table.insert(errors, "Not found: " .. tostring(target.path))
|
|
1831
|
+
else
|
|
1832
|
+
for key, value in target.properties do
|
|
1833
|
+
local coerced = coerceValue(instance, key, value)
|
|
1834
|
+
local ok, err = pcall(function()
|
|
1835
|
+
instance[key] = coerced
|
|
1836
|
+
end)
|
|
1837
|
+
if not ok then
|
|
1838
|
+
table.insert(errors, target.path .. "." .. key .. ": " .. tostring(err))
|
|
1839
|
+
end
|
|
1840
|
+
end
|
|
1841
|
+
modified = modified + 1
|
|
1842
|
+
end
|
|
1843
|
+
end
|
|
1844
|
+
elseif spec.root and (spec.properties or spec.addChildren) then
|
|
1845
|
+
-- Filter mode: apply to all matching descendants
|
|
1846
|
+
local root = Explorer.resolveInstance(spec.root)
|
|
1847
|
+
if not root then
|
|
1848
|
+
if recording then
|
|
1849
|
+
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
|
|
1850
|
+
end
|
|
1851
|
+
return { success = false, error = "Root not found: " .. tostring(spec.root) }
|
|
1852
|
+
end
|
|
1853
|
+
|
|
1854
|
+
local function processInstance(inst)
|
|
1855
|
+
if spec.className and inst.ClassName ~= spec.className then
|
|
1856
|
+
return
|
|
1857
|
+
end
|
|
1858
|
+
if spec.properties then
|
|
1859
|
+
for key, value in spec.properties do
|
|
1860
|
+
local coerced = coerceValue(inst, key, value)
|
|
1861
|
+
local ok, err = pcall(function()
|
|
1862
|
+
inst[key] = coerced
|
|
1863
|
+
end)
|
|
1864
|
+
if not ok then
|
|
1865
|
+
table.insert(errors, Explorer.getPath(inst) .. "." .. key .. ": " .. tostring(err))
|
|
1866
|
+
end
|
|
1867
|
+
end
|
|
1868
|
+
end
|
|
1869
|
+
-- addChildren: insert new child instances (e.g. UIStroke, UICorner)
|
|
1870
|
+
if spec.addChildren and type(spec.addChildren) == "table" then
|
|
1871
|
+
for _, childSpec in ipairs(spec.addChildren) do
|
|
1872
|
+
local childClass = childSpec.ClassName or childSpec.className
|
|
1873
|
+
if childClass then
|
|
1874
|
+
-- Skip if child of this class already exists (idempotent)
|
|
1875
|
+
local skipIfExists = childSpec.skipIfExists ~= false
|
|
1876
|
+
if skipIfExists then
|
|
1877
|
+
local existing = inst:FindFirstChildOfClass(childClass)
|
|
1878
|
+
if existing then
|
|
1879
|
+
continue
|
|
1880
|
+
end
|
|
1881
|
+
end
|
|
1882
|
+
local ok2, child = pcall(Instance.new, childClass)
|
|
1883
|
+
if ok2 and child then
|
|
1884
|
+
if childSpec.Name or childSpec.name then
|
|
1885
|
+
child.Name = childSpec.Name or childSpec.name
|
|
1886
|
+
end
|
|
1887
|
+
-- Set props on the new child
|
|
1888
|
+
local props = childSpec.properties
|
|
1889
|
+
or childSpec.Properties
|
|
1890
|
+
or childSpec.props
|
|
1891
|
+
or childSpec.Props
|
|
1892
|
+
or {}
|
|
1893
|
+
-- Also check flattened keys
|
|
1894
|
+
for k, v in childSpec do
|
|
1895
|
+
if
|
|
1896
|
+
k ~= "ClassName"
|
|
1897
|
+
and k ~= "className"
|
|
1898
|
+
and k ~= "Name"
|
|
1899
|
+
and k ~= "name"
|
|
1900
|
+
and k ~= "properties"
|
|
1901
|
+
and k ~= "Properties"
|
|
1902
|
+
and k ~= "props"
|
|
1903
|
+
and k ~= "Props"
|
|
1904
|
+
and k ~= "skipIfExists"
|
|
1905
|
+
and k ~= "Children"
|
|
1906
|
+
and k ~= "children"
|
|
1907
|
+
then
|
|
1908
|
+
props[k] = v
|
|
1909
|
+
end
|
|
1910
|
+
end
|
|
1911
|
+
for pk, pv in props do
|
|
1912
|
+
local coerced = coerceValue(child, pk, pv)
|
|
1913
|
+
pcall(function()
|
|
1914
|
+
child[pk] = coerced
|
|
1915
|
+
end)
|
|
1916
|
+
end
|
|
1917
|
+
child.Parent = inst
|
|
1918
|
+
else
|
|
1919
|
+
table.insert(
|
|
1920
|
+
errors,
|
|
1921
|
+
Explorer.getPath(inst) .. ": failed to create " .. tostring(childClass)
|
|
1922
|
+
)
|
|
1923
|
+
end
|
|
1924
|
+
end
|
|
1925
|
+
end
|
|
1926
|
+
end
|
|
1927
|
+
modified = modified + 1
|
|
1928
|
+
end
|
|
1929
|
+
|
|
1930
|
+
-- Include root itself if it matches
|
|
1931
|
+
processInstance(root)
|
|
1932
|
+
for _, desc in root:GetDescendants() do
|
|
1933
|
+
processInstance(desc)
|
|
1934
|
+
end
|
|
1935
|
+
else
|
|
1936
|
+
if recording then
|
|
1937
|
+
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
|
|
1938
|
+
end
|
|
1939
|
+
return { success = false, error = "Provide either 'targets' array or 'root' + 'properties'/'addChildren'" }
|
|
1940
|
+
end
|
|
1941
|
+
|
|
1942
|
+
if recording then
|
|
1943
|
+
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
|
|
1944
|
+
end
|
|
1945
|
+
|
|
1946
|
+
return { success = true, modified = modified, errors = errors }
|
|
1947
|
+
end
|
|
1948
|
+
|
|
1949
|
+
function Properties.inspectV2(spec)
|
|
1950
|
+
if type(spec.targets) ~= "table" or #spec.targets == 0 then
|
|
1951
|
+
return { success = false, error = "targets must contain at least one instance reference" }
|
|
1952
|
+
end
|
|
1953
|
+
if #spec.targets > 20 then
|
|
1954
|
+
return { success = false, error = "At most 20 instances can be inspected at once" }
|
|
1955
|
+
end
|
|
1956
|
+
local compact = spec.compact ~= false
|
|
1957
|
+
local results = {}
|
|
1958
|
+
|
|
1959
|
+
for index, targetRef in spec.targets do
|
|
1960
|
+
local instance, err = InstanceRegistry.resolve(targetRef)
|
|
1961
|
+
if not instance then
|
|
1962
|
+
table.insert(results, { success = false, index = index, error = err })
|
|
1963
|
+
continue
|
|
1964
|
+
end
|
|
1965
|
+
|
|
1966
|
+
local metadata = Reflection.getPropertyMetadata(instance.ClassName)
|
|
1967
|
+
local propertyNames = {}
|
|
1968
|
+
for propertyName in metadata do
|
|
1969
|
+
if not IGNORED_PROPERTIES[propertyName] and (not compact or not COMPACT_SKIP[propertyName]) then
|
|
1970
|
+
table.insert(propertyNames, propertyName)
|
|
1971
|
+
end
|
|
1972
|
+
end
|
|
1973
|
+
table.sort(propertyNames)
|
|
1974
|
+
|
|
1975
|
+
local properties = {}
|
|
1976
|
+
for _, propertyName in propertyNames do
|
|
1977
|
+
local readOk, value = pcall(function()
|
|
1978
|
+
return instance[propertyName]
|
|
1979
|
+
end)
|
|
1980
|
+
if readOk and value ~= nil then
|
|
1981
|
+
table.insert(properties, {
|
|
1982
|
+
name = propertyName,
|
|
1983
|
+
type = metadata[propertyName].type,
|
|
1984
|
+
category = metadata[propertyName].category,
|
|
1985
|
+
writable = metadata[propertyName].writable,
|
|
1986
|
+
value = ValueCodec.encode(value),
|
|
1987
|
+
})
|
|
1988
|
+
end
|
|
1989
|
+
end
|
|
1990
|
+
|
|
1991
|
+
local children = instance:GetChildren()
|
|
1992
|
+
table.sort(children, function(a, b)
|
|
1993
|
+
if a.Name == b.Name then
|
|
1994
|
+
return a.ClassName < b.ClassName
|
|
1995
|
+
end
|
|
1996
|
+
return a.Name < b.Name
|
|
1997
|
+
end)
|
|
1998
|
+
local childRefs = {}
|
|
1999
|
+
for _, child in children do
|
|
2000
|
+
table.insert(childRefs, {
|
|
2001
|
+
name = child.Name,
|
|
2002
|
+
className = child.ClassName,
|
|
2003
|
+
ref = InstanceRegistry.toRef(child),
|
|
2004
|
+
})
|
|
2005
|
+
end
|
|
2006
|
+
|
|
2007
|
+
table.insert(results, {
|
|
2008
|
+
success = true,
|
|
2009
|
+
name = instance.Name,
|
|
2010
|
+
className = instance.ClassName,
|
|
2011
|
+
ref = InstanceRegistry.toRef(instance),
|
|
2012
|
+
properties = properties,
|
|
2013
|
+
children = childRefs,
|
|
2014
|
+
})
|
|
2015
|
+
end
|
|
2016
|
+
|
|
2017
|
+
return { success = true, results = results }
|
|
2018
|
+
end
|
|
2019
|
+
|
|
2020
|
+
return Properties
|
|
2021
|
+
]]></string>
|
|
2022
|
+
</Properties>
|
|
2023
|
+
</Item>
|
|
2024
|
+
<Item class="ModuleScript" referent="7">
|
|
2025
|
+
<Properties>
|
|
2026
|
+
<string name="Name">Protocol</string>
|
|
2027
|
+
<string name="Source"><![CDATA[--[[
|
|
2028
|
+
Message protocol: JSON encoding/decoding with unique ID generation
|
|
2029
|
+
]]
|
|
2030
|
+
|
|
2031
|
+
local HttpService = game:GetService("HttpService")
|
|
2032
|
+
|
|
2033
|
+
local Protocol = {}
|
|
2034
|
+
|
|
2035
|
+
function Protocol.encode(data)
|
|
2036
|
+
return HttpService:JSONEncode(data)
|
|
2037
|
+
end
|
|
2038
|
+
|
|
2039
|
+
function Protocol.decode(json)
|
|
2040
|
+
return HttpService:JSONDecode(json)
|
|
2041
|
+
end
|
|
2042
|
+
|
|
2043
|
+
function Protocol.generateId()
|
|
2044
|
+
return HttpService:GenerateGUID(false)
|
|
2045
|
+
end
|
|
2046
|
+
|
|
2047
|
+
function Protocol.createMessage(msgType, payload)
|
|
2048
|
+
return {
|
|
2049
|
+
id = Protocol.generateId(),
|
|
2050
|
+
type = msgType,
|
|
2051
|
+
payload = payload,
|
|
2052
|
+
ts = os.clock(),
|
|
2053
|
+
}
|
|
2054
|
+
end
|
|
2055
|
+
|
|
2056
|
+
return Protocol
|
|
2057
|
+
]]></string>
|
|
2058
|
+
</Properties>
|
|
2059
|
+
</Item>
|
|
2060
|
+
<Item class="ModuleScript" referent="8">
|
|
2061
|
+
<Properties>
|
|
2062
|
+
<string name="Name">Reflection</string>
|
|
2063
|
+
<string name="Source"><![CDATA[local ReflectionService = game:GetService("ReflectionService")
|
|
2064
|
+
|
|
2065
|
+
local Reflection = {}
|
|
2066
|
+
|
|
2067
|
+
local function safeCall(callback)
|
|
2068
|
+
local ok, result = pcall(callback)
|
|
2069
|
+
if ok then
|
|
2070
|
+
return result
|
|
2071
|
+
end
|
|
2072
|
+
return nil, result
|
|
2073
|
+
end
|
|
2074
|
+
|
|
2075
|
+
local function typeName(reflectionType)
|
|
2076
|
+
if reflectionType == nil then
|
|
2077
|
+
return "unknown"
|
|
2078
|
+
end
|
|
2079
|
+
if type(reflectionType) == "table" then
|
|
2080
|
+
return reflectionType.Name or reflectionType.name or reflectionType.Type or tostring(reflectionType)
|
|
2081
|
+
end
|
|
2082
|
+
return tostring(reflectionType)
|
|
2083
|
+
end
|
|
2084
|
+
|
|
2085
|
+
local function displayCategory(member)
|
|
2086
|
+
if member.Display and member.Display.Category then
|
|
2087
|
+
return member.Display.Category
|
|
2088
|
+
end
|
|
2089
|
+
return "Other"
|
|
2090
|
+
end
|
|
2091
|
+
|
|
2092
|
+
local function permitString(permits, key)
|
|
2093
|
+
if not permits or permits[key] == nil then
|
|
2094
|
+
return nil
|
|
2095
|
+
end
|
|
2096
|
+
return tostring(permits[key])
|
|
2097
|
+
end
|
|
2098
|
+
|
|
2099
|
+
function Reflection.getClassInfo(className)
|
|
2100
|
+
local classData, classErr = safeCall(function()
|
|
2101
|
+
return ReflectionService:GetClass(className)
|
|
2102
|
+
end)
|
|
2103
|
+
|
|
2104
|
+
local properties = {}
|
|
2105
|
+
local propData = safeCall(function()
|
|
2106
|
+
return ReflectionService:GetPropertiesOfClass(className)
|
|
2107
|
+
end)
|
|
2108
|
+
if propData then
|
|
2109
|
+
for _, property in propData do
|
|
2110
|
+
table.insert(properties, {
|
|
2111
|
+
name = property.Name,
|
|
2112
|
+
type = typeName(property.Type),
|
|
2113
|
+
category = displayCategory(property),
|
|
2114
|
+
owner = property.Owner,
|
|
2115
|
+
serialized = property.Serialized,
|
|
2116
|
+
readPermission = permitString(property.Permits, "Read"),
|
|
2117
|
+
writePermission = permitString(property.Permits, "Write"),
|
|
2118
|
+
})
|
|
2119
|
+
end
|
|
2120
|
+
end
|
|
2121
|
+
|
|
2122
|
+
local methods = {}
|
|
2123
|
+
local methodData = safeCall(function()
|
|
2124
|
+
return ReflectionService:GetMethodsOfClass(className)
|
|
2125
|
+
end)
|
|
2126
|
+
if methodData then
|
|
2127
|
+
for _, method in methodData do
|
|
2128
|
+
local parameters = {}
|
|
2129
|
+
for _, parameter in method.Parameters or {} do
|
|
2130
|
+
table.insert(parameters, {
|
|
2131
|
+
name = parameter.Name,
|
|
2132
|
+
type = typeName(parameter.Type),
|
|
2133
|
+
defaultValue = parameter.DefaultValue,
|
|
2134
|
+
})
|
|
2135
|
+
end
|
|
2136
|
+
table.insert(methods, {
|
|
2137
|
+
name = method.Name,
|
|
2138
|
+
parameters = parameters,
|
|
2139
|
+
returnType = typeName(method.ReturnType),
|
|
2140
|
+
canYield = method.CanYield,
|
|
2141
|
+
owner = method.Owner,
|
|
2142
|
+
})
|
|
2143
|
+
end
|
|
2144
|
+
end
|
|
2145
|
+
|
|
2146
|
+
local events = {}
|
|
2147
|
+
local eventData = safeCall(function()
|
|
2148
|
+
return ReflectionService:GetEventsOfClass(className)
|
|
2149
|
+
end)
|
|
2150
|
+
if eventData then
|
|
2151
|
+
for _, event in eventData do
|
|
2152
|
+
local parameters = {}
|
|
2153
|
+
for _, parameter in event.Parameters or {} do
|
|
2154
|
+
table.insert(parameters, { name = parameter.Name, type = typeName(parameter.Type) })
|
|
2155
|
+
end
|
|
2156
|
+
table.insert(events, { name = event.Name, parameters = parameters, owner = event.Owner })
|
|
2157
|
+
end
|
|
2158
|
+
end
|
|
2159
|
+
|
|
2160
|
+
local result = {
|
|
2161
|
+
success = classData ~= nil or #properties > 0,
|
|
2162
|
+
className = className,
|
|
2163
|
+
properties = properties,
|
|
2164
|
+
methods = methods,
|
|
2165
|
+
events = events,
|
|
2166
|
+
}
|
|
2167
|
+
if classData then
|
|
2168
|
+
result.superclass = classData.Superclass and tostring(classData.Superclass) or nil
|
|
2169
|
+
result.newPermission = permitString(classData.Permits, "New")
|
|
2170
|
+
result.creatable = classData.Permits ~= nil and classData.Permits.New ~= nil
|
|
2171
|
+
end
|
|
2172
|
+
if classErr and #properties == 0 then
|
|
2173
|
+
result.error = "Could not reflect class: " .. tostring(classErr)
|
|
2174
|
+
end
|
|
2175
|
+
return result
|
|
2176
|
+
end
|
|
2177
|
+
|
|
2178
|
+
function Reflection.getPropertyTypes(className)
|
|
2179
|
+
local properties = safeCall(function()
|
|
2180
|
+
return ReflectionService:GetPropertiesOfClass(className)
|
|
2181
|
+
end)
|
|
2182
|
+
if not properties then
|
|
2183
|
+
return {}
|
|
2184
|
+
end
|
|
2185
|
+
local result = {}
|
|
2186
|
+
for _, property in properties do
|
|
2187
|
+
result[property.Name] = typeName(property.Type)
|
|
2188
|
+
end
|
|
2189
|
+
return result
|
|
2190
|
+
end
|
|
2191
|
+
|
|
2192
|
+
function Reflection.getPropertyMetadata(className)
|
|
2193
|
+
local properties = safeCall(function()
|
|
2194
|
+
return ReflectionService:GetPropertiesOfClass(className)
|
|
2195
|
+
end)
|
|
2196
|
+
if not properties then
|
|
2197
|
+
return {}
|
|
2198
|
+
end
|
|
2199
|
+
local result = {}
|
|
2200
|
+
for _, property in properties do
|
|
2201
|
+
result[property.Name] = {
|
|
2202
|
+
type = typeName(property.Type),
|
|
2203
|
+
category = displayCategory(property),
|
|
2204
|
+
writable = property.Permits == nil or property.Permits.Write ~= nil,
|
|
2205
|
+
}
|
|
2206
|
+
end
|
|
2207
|
+
return result
|
|
2208
|
+
end
|
|
2209
|
+
|
|
2210
|
+
function Reflection.listClasses()
|
|
2211
|
+
local classes = safeCall(function()
|
|
2212
|
+
return ReflectionService:GetClasses()
|
|
2213
|
+
end)
|
|
2214
|
+
if not classes then
|
|
2215
|
+
return {}
|
|
2216
|
+
end
|
|
2217
|
+
local result = {}
|
|
2218
|
+
for _, classData in classes do
|
|
2219
|
+
table.insert(result, {
|
|
2220
|
+
name = classData.Name,
|
|
2221
|
+
superclass = classData.Superclass and tostring(classData.Superclass) or nil,
|
|
2222
|
+
creatable = classData.Permits ~= nil and classData.Permits.New ~= nil,
|
|
2223
|
+
})
|
|
2224
|
+
end
|
|
2225
|
+
table.sort(result, function(a, b)
|
|
2226
|
+
return a.name < b.name
|
|
2227
|
+
end)
|
|
2228
|
+
return result
|
|
2229
|
+
end
|
|
2230
|
+
|
|
2231
|
+
return Reflection
|
|
2232
|
+
]]></string>
|
|
2233
|
+
</Properties>
|
|
2234
|
+
</Item>
|
|
2235
|
+
<Item class="ModuleScript" referent="9">
|
|
2236
|
+
<Properties>
|
|
2237
|
+
<string name="Name">TestRunner</string>
|
|
2238
|
+
<string name="Source"><![CDATA[local StudioTestService = game:GetService("StudioTestService")
|
|
2239
|
+
|
|
2240
|
+
local TestRunner = {}
|
|
2241
|
+
local activeRunId = nil
|
|
2242
|
+
|
|
2243
|
+
function TestRunner.executeV2(spec)
|
|
2244
|
+
if activeRunId then
|
|
2245
|
+
return { success = false, error = "A Dominus Studio test is already running" }
|
|
2246
|
+
end
|
|
2247
|
+
|
|
2248
|
+
local mode = spec.mode or "run"
|
|
2249
|
+
if mode ~= "run" and mode ~= "play" and mode ~= "multiplayer" then
|
|
2250
|
+
return { success = false, error = "mode must be run, play, or multiplayer" }
|
|
2251
|
+
end
|
|
2252
|
+
local timeoutSeconds = math.clamp((spec.timeoutMs or 120000) / 1000, 5, 600)
|
|
2253
|
+
local players = math.clamp(spec.players or 1, 1, 8)
|
|
2254
|
+
local runId = tostring(os.clock()) .. ":" .. mode
|
|
2255
|
+
activeRunId = runId
|
|
2256
|
+
local timedOut = false
|
|
2257
|
+
|
|
2258
|
+
task.delay(timeoutSeconds, function()
|
|
2259
|
+
if activeRunId ~= runId then
|
|
2260
|
+
return
|
|
2261
|
+
end
|
|
2262
|
+
timedOut = true
|
|
2263
|
+
pcall(function()
|
|
2264
|
+
if StudioTestService:CanLeaveTest() then
|
|
2265
|
+
StudioTestService:LeaveTest()
|
|
2266
|
+
end
|
|
2267
|
+
end)
|
|
2268
|
+
end)
|
|
2269
|
+
|
|
2270
|
+
local startedAt = os.clock()
|
|
2271
|
+
local ok, result = pcall(function()
|
|
2272
|
+
if mode == "run" then
|
|
2273
|
+
return StudioTestService:ExecuteRunModeAsync(spec.args)
|
|
2274
|
+
elseif mode == "play" then
|
|
2275
|
+
return StudioTestService:ExecutePlayModeAsync(spec.args)
|
|
2276
|
+
end
|
|
2277
|
+
return StudioTestService:ExecuteMultiplayerTestAsync(players, spec.args)
|
|
2278
|
+
end)
|
|
2279
|
+
local duration = os.clock() - startedAt
|
|
2280
|
+
if activeRunId == runId then
|
|
2281
|
+
activeRunId = nil
|
|
2282
|
+
end
|
|
2283
|
+
|
|
2284
|
+
if timedOut then
|
|
2285
|
+
return { success = false, timedOut = true, error = "Studio test exceeded its timeout", duration = duration }
|
|
2286
|
+
end
|
|
2287
|
+
if not ok then
|
|
2288
|
+
return { success = false, error = tostring(result), duration = duration }
|
|
2289
|
+
end
|
|
2290
|
+
return {
|
|
2291
|
+
success = true,
|
|
2292
|
+
mode = mode,
|
|
2293
|
+
players = mode == "multiplayer" and players or nil,
|
|
2294
|
+
result = result,
|
|
2295
|
+
duration = duration,
|
|
2296
|
+
}
|
|
2297
|
+
end
|
|
2298
|
+
|
|
2299
|
+
function TestRunner.endTest(value)
|
|
2300
|
+
local ok, err = pcall(function()
|
|
2301
|
+
StudioTestService:EndTest(value)
|
|
2302
|
+
end)
|
|
2303
|
+
return ok and { success = true } or { success = false, error = tostring(err) }
|
|
2304
|
+
end
|
|
2305
|
+
|
|
2306
|
+
return TestRunner
|
|
2307
|
+
]]></string>
|
|
2308
|
+
</Properties>
|
|
2309
|
+
</Item>
|
|
2310
|
+
<Item class="ModuleScript" referent="10">
|
|
2311
|
+
<Properties>
|
|
2312
|
+
<string name="Name">UIBuilder</string>
|
|
2313
|
+
<string name="Source"><![CDATA[--[[
|
|
2314
|
+
UIBuilder: create entire UI trees from a declarative JSON spec in one call.
|
|
2315
|
+
Handles all type coercion natively in Luau — no round trips needed.
|
|
2316
|
+
]]
|
|
2317
|
+
|
|
2318
|
+
local Explorer = require(script.Parent.Explorer)
|
|
2319
|
+
local InstanceRegistry = require(script.Parent.InstanceRegistry)
|
|
2320
|
+
local Reflection = require(script.Parent.Reflection)
|
|
2321
|
+
local ValueCodec = require(script.Parent.ValueCodec)
|
|
2322
|
+
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
|
2323
|
+
|
|
2324
|
+
local UIBuilder = {}
|
|
2325
|
+
|
|
2326
|
+
-- Cache property type maps per ClassName to avoid repeated reflection calls
|
|
2327
|
+
local propertyTypeCache = {}
|
|
2328
|
+
local function getPropertyType(className, propName)
|
|
2329
|
+
if not propertyTypeCache[className] then
|
|
2330
|
+
propertyTypeCache[className] = Reflection.getPropertyTypes(className)
|
|
2331
|
+
end
|
|
2332
|
+
return propertyTypeCache[className][propName]
|
|
2333
|
+
end
|
|
2334
|
+
|
|
2335
|
+
local function resolveColor(value)
|
|
2336
|
+
if type(value) == "string" then
|
|
2337
|
+
if value:sub(1, 1) == "#" then
|
|
2338
|
+
return Color3.fromHex(value)
|
|
2339
|
+
end
|
|
2340
|
+
local r, g, b = value:match("rgb%s*%((%d+)%D+(%d+)%D+(%d+)%)")
|
|
2341
|
+
if r then
|
|
2342
|
+
return Color3.fromRGB(tonumber(r), tonumber(g), tonumber(b))
|
|
2343
|
+
end
|
|
2344
|
+
local ok, bc = pcall(BrickColor.new, value)
|
|
2345
|
+
if ok then
|
|
2346
|
+
return bc.Color
|
|
2347
|
+
end
|
|
2348
|
+
end
|
|
2349
|
+
if type(value) == "table" then
|
|
2350
|
+
if value[1] then
|
|
2351
|
+
if value[1] > 1 or value[2] > 1 or value[3] > 1 then
|
|
2352
|
+
return Color3.fromRGB(value[1], value[2], value[3])
|
|
2353
|
+
end
|
|
2354
|
+
return Color3.new(value[1], value[2], value[3])
|
|
2355
|
+
end
|
|
2356
|
+
if value.R or value.r then
|
|
2357
|
+
return Color3.new(value.R or value.r or 0, value.G or value.g or 0, value.B or value.b or 0)
|
|
2358
|
+
end
|
|
2359
|
+
end
|
|
2360
|
+
return Color3.new(1, 1, 1)
|
|
2361
|
+
end
|
|
2362
|
+
|
|
2363
|
+
local function resolveUDim2(value)
|
|
2364
|
+
if type(value) == "table" then
|
|
2365
|
+
if value.XScale or value.xScale then
|
|
2366
|
+
return UDim2.new(
|
|
2367
|
+
value.XScale or value.xScale or 0,
|
|
2368
|
+
value.XOffset or value.xOffset or 0,
|
|
2369
|
+
value.YScale or value.yScale or 0,
|
|
2370
|
+
value.YOffset or value.yOffset or 0
|
|
2371
|
+
)
|
|
2372
|
+
end
|
|
2373
|
+
if value.X and type(value.X) == "table" then
|
|
2374
|
+
return UDim2.new(
|
|
2375
|
+
value.X.Scale or value.X[1] or 0,
|
|
2376
|
+
value.X.Offset or value.X[2] or 0,
|
|
2377
|
+
value.Y and (value.Y.Scale or value.Y[1]) or 0,
|
|
2378
|
+
value.Y and (value.Y.Offset or value.Y[2]) or 0
|
|
2379
|
+
)
|
|
2380
|
+
end
|
|
2381
|
+
if value[1] ~= nil then
|
|
2382
|
+
return UDim2.new(value[1] or 0, value[2] or 0, value[3] or 0, value[4] or 0)
|
|
2383
|
+
end
|
|
2384
|
+
end
|
|
2385
|
+
if type(value) == "string" then
|
|
2386
|
+
local a, b, c, d = value:match("([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)")
|
|
2387
|
+
if a then
|
|
2388
|
+
return UDim2.new(tonumber(a), tonumber(b), tonumber(c), tonumber(d))
|
|
2389
|
+
end
|
|
2390
|
+
end
|
|
2391
|
+
return UDim2.new(0, 0, 0, 0)
|
|
2392
|
+
end
|
|
2393
|
+
|
|
2394
|
+
local function resolveUDim(value)
|
|
2395
|
+
if type(value) == "table" then
|
|
2396
|
+
return UDim.new(value.Scale or value[1] or 0, value.Offset or value[2] or 0)
|
|
2397
|
+
end
|
|
2398
|
+
if type(value) == "number" then
|
|
2399
|
+
return UDim.new(0, value)
|
|
2400
|
+
end
|
|
2401
|
+
return UDim.new(0, 0)
|
|
2402
|
+
end
|
|
2403
|
+
|
|
2404
|
+
local function resolveVector2(value)
|
|
2405
|
+
if type(value) == "table" then
|
|
2406
|
+
return Vector2.new(value.X or value.x or value[1] or 0, value.Y or value.y or value[2] or 0)
|
|
2407
|
+
end
|
|
2408
|
+
return Vector2.new(0, 0)
|
|
2409
|
+
end
|
|
2410
|
+
|
|
2411
|
+
local function resolveEnum(instance, key, value)
|
|
2412
|
+
if type(value) ~= "string" then
|
|
2413
|
+
return value
|
|
2414
|
+
end
|
|
2415
|
+
local curOk, curVal = pcall(function()
|
|
2416
|
+
return instance[key]
|
|
2417
|
+
end)
|
|
2418
|
+
if curOk and typeof(curVal) == "EnumItem" then
|
|
2419
|
+
local enumType = tostring(curVal.EnumType)
|
|
2420
|
+
local ok, enumVal = pcall(function()
|
|
2421
|
+
return Enum[enumType][value]
|
|
2422
|
+
end)
|
|
2423
|
+
if ok then
|
|
2424
|
+
return enumVal
|
|
2425
|
+
end
|
|
2426
|
+
end
|
|
2427
|
+
return value
|
|
2428
|
+
end
|
|
2429
|
+
|
|
2430
|
+
local function resolveFont(value)
|
|
2431
|
+
if type(value) == "string" then
|
|
2432
|
+
local ok, font = pcall(function()
|
|
2433
|
+
return Enum.Font[value]
|
|
2434
|
+
end)
|
|
2435
|
+
if ok then
|
|
2436
|
+
return font
|
|
2437
|
+
end
|
|
2438
|
+
end
|
|
2439
|
+
return value
|
|
2440
|
+
end
|
|
2441
|
+
|
|
2442
|
+
local UDIM2_PROPS = {
|
|
2443
|
+
Size = true,
|
|
2444
|
+
Position = true,
|
|
2445
|
+
CellSize = true,
|
|
2446
|
+
CellPadding = true,
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
local COLOR3_PROPS = {
|
|
2450
|
+
BackgroundColor3 = true,
|
|
2451
|
+
BorderColor3 = true,
|
|
2452
|
+
TextColor3 = true,
|
|
2453
|
+
ImageColor3 = true,
|
|
2454
|
+
PlaceholderColor3 = true,
|
|
2455
|
+
TextStrokeColor3 = true,
|
|
2456
|
+
ScrollBarImageColor3 = true,
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
local function resolveColorSequence(value)
|
|
2460
|
+
if type(value) == "table" then
|
|
2461
|
+
-- Array of keypoints: {{time, color}, ...}
|
|
2462
|
+
if type(value[1]) == "table" then
|
|
2463
|
+
local keypoints = {}
|
|
2464
|
+
for _, kp in ipairs(value) do
|
|
2465
|
+
local t = kp[1] or kp.Time or 0
|
|
2466
|
+
local c = resolveColor(kp[2] or kp.Color or kp)
|
|
2467
|
+
table.insert(keypoints, ColorSequenceKeypoint.new(t, c))
|
|
2468
|
+
end
|
|
2469
|
+
return ColorSequence.new(keypoints)
|
|
2470
|
+
end
|
|
2471
|
+
-- Single color as array [r,g,b] → uniform ColorSequence
|
|
2472
|
+
if type(value[1]) == "number" and #value == 3 then
|
|
2473
|
+
local c = resolveColor(value)
|
|
2474
|
+
return ColorSequence.new(c)
|
|
2475
|
+
end
|
|
2476
|
+
end
|
|
2477
|
+
-- String hex/rgb → uniform ColorSequence
|
|
2478
|
+
if type(value) == "string" then
|
|
2479
|
+
return ColorSequence.new(resolveColor(value))
|
|
2480
|
+
end
|
|
2481
|
+
return ColorSequence.new(Color3.new(1, 1, 1))
|
|
2482
|
+
end
|
|
2483
|
+
|
|
2484
|
+
local function resolveNumberSequence(value)
|
|
2485
|
+
if type(value) == "table" then
|
|
2486
|
+
-- Array of keypoints: {{time, value, envelope?}, ...}
|
|
2487
|
+
if type(value[1]) == "table" then
|
|
2488
|
+
local keypoints = {}
|
|
2489
|
+
for _, kp in ipairs(value) do
|
|
2490
|
+
local t = kp[1] or kp.Time or 0
|
|
2491
|
+
local v = kp[2] or kp.Value or 0
|
|
2492
|
+
local e = kp[3] or kp.Envelope or 0
|
|
2493
|
+
table.insert(keypoints, NumberSequenceKeypoint.new(t, v, e))
|
|
2494
|
+
end
|
|
2495
|
+
return NumberSequence.new(keypoints)
|
|
2496
|
+
end
|
|
2497
|
+
end
|
|
2498
|
+
if type(value) == "number" then
|
|
2499
|
+
return NumberSequence.new(value)
|
|
2500
|
+
end
|
|
2501
|
+
return NumberSequence.new(0)
|
|
2502
|
+
end
|
|
2503
|
+
|
|
2504
|
+
local VECTOR2_PROPS = {
|
|
2505
|
+
AnchorPoint = true,
|
|
2506
|
+
CanvasSize_v2 = true,
|
|
2507
|
+
AbsoluteSize = true,
|
|
2508
|
+
AbsolutePosition = true,
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
local UDIM_PROPS = {
|
|
2512
|
+
CornerRadius = true,
|
|
2513
|
+
Padding = true,
|
|
2514
|
+
PaddingTop = true,
|
|
2515
|
+
PaddingBottom = true,
|
|
2516
|
+
PaddingLeft = true,
|
|
2517
|
+
PaddingRight = true,
|
|
2518
|
+
FillDirection = false, -- not UDim
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
local NUMBER_RANGE_PROPS = {
|
|
2522
|
+
Range = true,
|
|
2523
|
+
RotationRange = true,
|
|
2524
|
+
Size_NR = true, -- particle size range alias
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
local RECT_PROPS = {
|
|
2528
|
+
SliceCenter = true,
|
|
2529
|
+
ImageRectOffset_Rect = true,
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
local function resolveNumberRange(value)
|
|
2533
|
+
if type(value) == "table" then
|
|
2534
|
+
return NumberRange.new(value[1] or 0, value[2] or value[1] or 0)
|
|
2535
|
+
end
|
|
2536
|
+
if type(value) == "number" then
|
|
2537
|
+
return NumberRange.new(value)
|
|
2538
|
+
end
|
|
2539
|
+
return NumberRange.new(0)
|
|
2540
|
+
end
|
|
2541
|
+
|
|
2542
|
+
local function resolveRect(value)
|
|
2543
|
+
if type(value) == "table" then
|
|
2544
|
+
if #value == 4 then
|
|
2545
|
+
return Rect.new(value[1], value[2], value[3], value[4])
|
|
2546
|
+
end
|
|
2547
|
+
return Rect.new(
|
|
2548
|
+
value.Min and value.Min[1] or value.MinX or 0,
|
|
2549
|
+
value.Min and value.Min[2] or value.MinY or 0,
|
|
2550
|
+
value.Max and value.Max[1] or value.MaxX or 0,
|
|
2551
|
+
value.Max and value.Max[2] or value.MaxY or 0
|
|
2552
|
+
)
|
|
2553
|
+
end
|
|
2554
|
+
return Rect.new(0, 0, 0, 0)
|
|
2555
|
+
end
|
|
2556
|
+
|
|
2557
|
+
-- Disambiguate 2-element arrays: check actual property type on the instance
|
|
2558
|
+
local function resolve2ElementArray(instance, key, value)
|
|
2559
|
+
-- First, check our known lookup tables
|
|
2560
|
+
if VECTOR2_PROPS[key] then
|
|
2561
|
+
return resolveVector2(value)
|
|
2562
|
+
end
|
|
2563
|
+
if UDIM_PROPS[key] then
|
|
2564
|
+
return resolveUDim(value)
|
|
2565
|
+
end
|
|
2566
|
+
if NUMBER_RANGE_PROPS[key] then
|
|
2567
|
+
return resolveNumberRange(value)
|
|
2568
|
+
end
|
|
2569
|
+
-- Fall back to runtime type introspection
|
|
2570
|
+
local ok, curVal = pcall(function()
|
|
2571
|
+
return instance[key]
|
|
2572
|
+
end)
|
|
2573
|
+
if ok and curVal ~= nil then
|
|
2574
|
+
local t = typeof(curVal)
|
|
2575
|
+
if t == "Vector2" then
|
|
2576
|
+
return resolveVector2(value)
|
|
2577
|
+
end
|
|
2578
|
+
if t == "UDim" then
|
|
2579
|
+
return resolveUDim(value)
|
|
2580
|
+
end
|
|
2581
|
+
if t == "NumberRange" then
|
|
2582
|
+
return resolveNumberRange(value)
|
|
2583
|
+
end
|
|
2584
|
+
end
|
|
2585
|
+
-- Default: assume UDim for 2-element (more common in UI)
|
|
2586
|
+
return resolveUDim(value)
|
|
2587
|
+
end
|
|
2588
|
+
|
|
2589
|
+
local function applyProperty(instance, key, value)
|
|
2590
|
+
-- Special handling by property name
|
|
2591
|
+
if key == "Size" or key == "Position" then
|
|
2592
|
+
if instance:IsA("GuiObject") or instance:IsA("UIBase") then
|
|
2593
|
+
instance[key] = resolveUDim2(value)
|
|
2594
|
+
return
|
|
2595
|
+
end
|
|
2596
|
+
end
|
|
2597
|
+
|
|
2598
|
+
if key == "AnchorPoint" then
|
|
2599
|
+
if instance:IsA("GuiObject") then
|
|
2600
|
+
instance[key] = resolveVector2(value)
|
|
2601
|
+
return
|
|
2602
|
+
end
|
|
2603
|
+
end
|
|
2604
|
+
|
|
2605
|
+
if key == "CanvasSize" and instance:IsA("ScrollingFrame") then
|
|
2606
|
+
instance[key] = resolveUDim2(value)
|
|
2607
|
+
return
|
|
2608
|
+
end
|
|
2609
|
+
|
|
2610
|
+
if UDIM_PROPS[key] then
|
|
2611
|
+
instance[key] = resolveUDim(value)
|
|
2612
|
+
return
|
|
2613
|
+
end
|
|
2614
|
+
|
|
2615
|
+
-- Use reflection to distinguish Color3 vs ColorSequence, NumberSequence, etc.
|
|
2616
|
+
local propType = getPropertyType(instance.ClassName, key)
|
|
2617
|
+
|
|
2618
|
+
if propType == "ColorSequence" then
|
|
2619
|
+
instance[key] = resolveColorSequence(value)
|
|
2620
|
+
return
|
|
2621
|
+
end
|
|
2622
|
+
|
|
2623
|
+
if propType == "NumberSequence" then
|
|
2624
|
+
instance[key] = resolveNumberSequence(value)
|
|
2625
|
+
return
|
|
2626
|
+
end
|
|
2627
|
+
|
|
2628
|
+
if COLOR3_PROPS[key] or propType == "Color3" then
|
|
2629
|
+
instance[key] = resolveColor(value)
|
|
2630
|
+
return
|
|
2631
|
+
end
|
|
2632
|
+
|
|
2633
|
+
-- Reflection-based coercion for types not in hardcoded tables
|
|
2634
|
+
if propType == "UDim2" and type(value) == "table" then
|
|
2635
|
+
instance[key] = resolveUDim2(value)
|
|
2636
|
+
return
|
|
2637
|
+
end
|
|
2638
|
+
if propType == "UDim" then
|
|
2639
|
+
instance[key] = resolveUDim(value)
|
|
2640
|
+
return
|
|
2641
|
+
end
|
|
2642
|
+
if propType == "Vector2" then
|
|
2643
|
+
instance[key] = resolveVector2(value)
|
|
2644
|
+
return
|
|
2645
|
+
end
|
|
2646
|
+
if propType == "NumberRange" then
|
|
2647
|
+
instance[key] = resolveNumberRange(value)
|
|
2648
|
+
return
|
|
2649
|
+
end
|
|
2650
|
+
if propType == "Rect" then
|
|
2651
|
+
instance[key] = resolveRect(value)
|
|
2652
|
+
return
|
|
2653
|
+
end
|
|
2654
|
+
|
|
2655
|
+
-- Rect properties (4-element arrays like SliceCenter)
|
|
2656
|
+
if RECT_PROPS[key] then
|
|
2657
|
+
instance[key] = resolveRect(value)
|
|
2658
|
+
return
|
|
2659
|
+
end
|
|
2660
|
+
|
|
2661
|
+
-- NumberRange properties
|
|
2662
|
+
if NUMBER_RANGE_PROPS[key] then
|
|
2663
|
+
instance[key] = resolveNumberRange(value)
|
|
2664
|
+
return
|
|
2665
|
+
end
|
|
2666
|
+
|
|
2667
|
+
if key == "Font" and (instance:IsA("TextLabel") or instance:IsA("TextButton") or instance:IsA("TextBox")) then
|
|
2668
|
+
instance[key] = resolveFont(value)
|
|
2669
|
+
return
|
|
2670
|
+
end
|
|
2671
|
+
|
|
2672
|
+
if key == "FontFace" then
|
|
2673
|
+
if type(value) == "table" then
|
|
2674
|
+
local family = value.Family or "rbxasset://fonts/families/SourceSansPro.json"
|
|
2675
|
+
local weight = Enum.FontWeight.Regular
|
|
2676
|
+
local style = Enum.FontStyle.Normal
|
|
2677
|
+
pcall(function()
|
|
2678
|
+
weight = Enum.FontWeight[value.Weight or "Regular"]
|
|
2679
|
+
end)
|
|
2680
|
+
pcall(function()
|
|
2681
|
+
style = Enum.FontStyle[value.Style or "Normal"]
|
|
2682
|
+
end)
|
|
2683
|
+
instance.FontFace = Font.new(family, weight, style)
|
|
2684
|
+
end
|
|
2685
|
+
return
|
|
2686
|
+
end
|
|
2687
|
+
|
|
2688
|
+
-- Enum properties (string values)
|
|
2689
|
+
if type(value) == "string" then
|
|
2690
|
+
local resolved = resolveEnum(instance, key, value)
|
|
2691
|
+
local ok, err = pcall(function()
|
|
2692
|
+
instance[key] = resolved
|
|
2693
|
+
end)
|
|
2694
|
+
if not ok then
|
|
2695
|
+
-- Maybe it's a direct value
|
|
2696
|
+
pcall(function()
|
|
2697
|
+
instance[key] = value
|
|
2698
|
+
end)
|
|
2699
|
+
end
|
|
2700
|
+
return
|
|
2701
|
+
end
|
|
2702
|
+
|
|
2703
|
+
if UDIM2_PROPS[key] then
|
|
2704
|
+
pcall(function()
|
|
2705
|
+
instance[key] = resolveUDim2(value)
|
|
2706
|
+
end)
|
|
2707
|
+
return
|
|
2708
|
+
end
|
|
2709
|
+
|
|
2710
|
+
-- Smart disambiguation for 2-element table arrays
|
|
2711
|
+
if type(value) == "table" and #value == 2 then
|
|
2712
|
+
local resolved = resolve2ElementArray(instance, key, value)
|
|
2713
|
+
pcall(function()
|
|
2714
|
+
instance[key] = resolved
|
|
2715
|
+
end)
|
|
2716
|
+
return
|
|
2717
|
+
end
|
|
2718
|
+
|
|
2719
|
+
-- 4-element table arrays: try Rect if not already handled as UDim2
|
|
2720
|
+
if type(value) == "table" and #value == 4 then
|
|
2721
|
+
-- Check actual property type
|
|
2722
|
+
local ok, curVal = pcall(function()
|
|
2723
|
+
return instance[key]
|
|
2724
|
+
end)
|
|
2725
|
+
if ok and curVal ~= nil then
|
|
2726
|
+
local t = typeof(curVal)
|
|
2727
|
+
if t == "Rect" then
|
|
2728
|
+
pcall(function()
|
|
2729
|
+
instance[key] = resolveRect(value)
|
|
2730
|
+
end)
|
|
2731
|
+
return
|
|
2732
|
+
elseif t == "UDim2" then
|
|
2733
|
+
pcall(function()
|
|
2734
|
+
instance[key] = resolveUDim2(value)
|
|
2735
|
+
end)
|
|
2736
|
+
return
|
|
2737
|
+
end
|
|
2738
|
+
end
|
|
2739
|
+
-- Default 4-element to UDim2
|
|
2740
|
+
pcall(function()
|
|
2741
|
+
instance[key] = resolveUDim2(value)
|
|
2742
|
+
end)
|
|
2743
|
+
return
|
|
2744
|
+
end
|
|
2745
|
+
|
|
2746
|
+
-- Direct assignment for numbers, booleans, etc.
|
|
2747
|
+
pcall(function()
|
|
2748
|
+
instance[key] = value
|
|
2749
|
+
end)
|
|
2750
|
+
end
|
|
2751
|
+
|
|
2752
|
+
local RESERVED_KEYS = {
|
|
2753
|
+
ClassName = true,
|
|
2754
|
+
className = true,
|
|
2755
|
+
Name = true,
|
|
2756
|
+
name = true,
|
|
2757
|
+
Children = true,
|
|
2758
|
+
children = true,
|
|
2759
|
+
properties = true,
|
|
2760
|
+
Properties = true,
|
|
2761
|
+
props = true,
|
|
2762
|
+
Props = true,
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
local function buildNode(spec, parent, animate)
|
|
2766
|
+
local className = spec.ClassName or spec.className or "Frame"
|
|
2767
|
+
local name = spec.Name or spec.name or className
|
|
2768
|
+
|
|
2769
|
+
local instance = Instance.new(className)
|
|
2770
|
+
instance.Name = name
|
|
2771
|
+
|
|
2772
|
+
-- Apply flattened top-level properties
|
|
2773
|
+
for key, value in spec do
|
|
2774
|
+
if not RESERVED_KEYS[key] then
|
|
2775
|
+
applyProperty(instance, key, value)
|
|
2776
|
+
end
|
|
2777
|
+
end
|
|
2778
|
+
|
|
2779
|
+
-- Also accept a nested "properties" / "Properties" / "props" bag for convenience.
|
|
2780
|
+
-- This lets callers group properties explicitly without the key being misread
|
|
2781
|
+
-- as an instance property name.
|
|
2782
|
+
local propBag = spec.properties or spec.Properties or spec.props or spec.Props
|
|
2783
|
+
if type(propBag) == "table" then
|
|
2784
|
+
for key, value in propBag do
|
|
2785
|
+
if not RESERVED_KEYS[key] then
|
|
2786
|
+
applyProperty(instance, key, value)
|
|
2787
|
+
end
|
|
2788
|
+
end
|
|
2789
|
+
end
|
|
2790
|
+
|
|
2791
|
+
-- Set parent after properties to avoid unnecessary layout computations
|
|
2792
|
+
instance.Parent = parent
|
|
2793
|
+
|
|
2794
|
+
-- Build children recursively
|
|
2795
|
+
local children = spec.Children or spec.children
|
|
2796
|
+
if children then
|
|
2797
|
+
for _, childSpec in children do
|
|
2798
|
+
if animate then
|
|
2799
|
+
task.wait(0.05)
|
|
2800
|
+
end
|
|
2801
|
+
buildNode(childSpec, instance, animate)
|
|
2802
|
+
end
|
|
2803
|
+
end
|
|
2804
|
+
|
|
2805
|
+
return instance
|
|
2806
|
+
end
|
|
2807
|
+
|
|
2808
|
+
function UIBuilder.build(spec)
|
|
2809
|
+
local parentPath = spec.parent or "StarterGui"
|
|
2810
|
+
local parent = Explorer.resolveInstance(parentPath)
|
|
2811
|
+
if not parent then
|
|
2812
|
+
-- Try as a service
|
|
2813
|
+
local ok
|
|
2814
|
+
ok, parent = pcall(function()
|
|
2815
|
+
return game:GetService(parentPath)
|
|
2816
|
+
end)
|
|
2817
|
+
if not ok then
|
|
2818
|
+
return { success = false, error = "Parent not found: " .. parentPath }
|
|
2819
|
+
end
|
|
2820
|
+
end
|
|
2821
|
+
|
|
2822
|
+
-- Clean up existing instance with same name
|
|
2823
|
+
local existingName = spec.tree and (spec.tree.Name or spec.tree.name) or nil
|
|
2824
|
+
if existingName then
|
|
2825
|
+
local existing = parent:FindFirstChild(existingName)
|
|
2826
|
+
if existing then
|
|
2827
|
+
existing:Destroy()
|
|
2828
|
+
end
|
|
2829
|
+
end
|
|
2830
|
+
|
|
2831
|
+
local tree = spec.tree
|
|
2832
|
+
if not tree then
|
|
2833
|
+
return { success = false, error = "No 'tree' provided in spec" }
|
|
2834
|
+
end
|
|
2835
|
+
|
|
2836
|
+
local animate = false
|
|
2837
|
+
if spec.animate ~= nil then
|
|
2838
|
+
animate = spec.animate
|
|
2839
|
+
end
|
|
2840
|
+
|
|
2841
|
+
local ok, result = pcall(function()
|
|
2842
|
+
return buildNode(tree, parent, animate)
|
|
2843
|
+
end)
|
|
2844
|
+
|
|
2845
|
+
if not ok then
|
|
2846
|
+
return { success = false, error = "Build failed: " .. tostring(result) }
|
|
2847
|
+
end
|
|
2848
|
+
|
|
2849
|
+
local path = Explorer.getPath(result)
|
|
2850
|
+
local count = 0
|
|
2851
|
+
local function countDescendants(inst)
|
|
2852
|
+
count = count + 1
|
|
2853
|
+
for _, child in inst:GetChildren() do
|
|
2854
|
+
countDescendants(child)
|
|
2855
|
+
end
|
|
2856
|
+
end
|
|
2857
|
+
countDescendants(result)
|
|
2858
|
+
|
|
2859
|
+
return { success = true, path = path, instanceCount = count }
|
|
2860
|
+
end
|
|
2861
|
+
|
|
2862
|
+
--[[
|
|
2863
|
+
Serialize: convert an existing instance tree into create_ui-compatible JSON.
|
|
2864
|
+
Reads properties via ReflectionService, converts Roblox types to JSON-friendly values,
|
|
2865
|
+
and skips default/unchanged values to keep output minimal.
|
|
2866
|
+
]]
|
|
2867
|
+
|
|
2868
|
+
local Reflection = require(script.Parent.Reflection)
|
|
2869
|
+
local ReflectionService = game:GetService("ReflectionService")
|
|
2870
|
+
|
|
2871
|
+
-- Convert a Roblox value to a JSON-friendly representation
|
|
2872
|
+
local function valueToJson(value, propType)
|
|
2873
|
+
local t = typeof(value)
|
|
2874
|
+
|
|
2875
|
+
if t == "UDim2" then
|
|
2876
|
+
return { value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset }
|
|
2877
|
+
end
|
|
2878
|
+
if t == "UDim" then
|
|
2879
|
+
return { value.Scale, value.Offset }
|
|
2880
|
+
end
|
|
2881
|
+
if t == "Vector2" then
|
|
2882
|
+
return { value.X, value.Y }
|
|
2883
|
+
end
|
|
2884
|
+
if t == "Vector3" then
|
|
2885
|
+
return { value.X, value.Y, value.Z }
|
|
2886
|
+
end
|
|
2887
|
+
if t == "Color3" then
|
|
2888
|
+
return "#" .. value:ToHex()
|
|
2889
|
+
end
|
|
2890
|
+
if t == "BrickColor" then
|
|
2891
|
+
return value.Name
|
|
2892
|
+
end
|
|
2893
|
+
if t == "ColorSequence" then
|
|
2894
|
+
local kps = {}
|
|
2895
|
+
for _, kp in value.Keypoints do
|
|
2896
|
+
table.insert(kps, { kp.Time, "#" .. kp.Value:ToHex() })
|
|
2897
|
+
end
|
|
2898
|
+
return kps
|
|
2899
|
+
end
|
|
2900
|
+
if t == "NumberSequence" then
|
|
2901
|
+
local kps = {}
|
|
2902
|
+
for _, kp in value.Keypoints do
|
|
2903
|
+
table.insert(kps, { kp.Time, kp.Value, kp.Envelope })
|
|
2904
|
+
end
|
|
2905
|
+
return kps
|
|
2906
|
+
end
|
|
2907
|
+
if t == "NumberRange" then
|
|
2908
|
+
return { value.Min, value.Max }
|
|
2909
|
+
end
|
|
2910
|
+
if t == "Rect" then
|
|
2911
|
+
return { value.Min.X, value.Min.Y, value.Max.X, value.Max.Y }
|
|
2912
|
+
end
|
|
2913
|
+
if t == "CFrame" then
|
|
2914
|
+
return { value:GetComponents() }
|
|
2915
|
+
end
|
|
2916
|
+
if t == "EnumItem" then
|
|
2917
|
+
return value.Name
|
|
2918
|
+
end
|
|
2919
|
+
if t == "Font" then
|
|
2920
|
+
return {
|
|
2921
|
+
Family = value.Family,
|
|
2922
|
+
Weight = value.Weight.Name,
|
|
2923
|
+
Style = value.Style.Name,
|
|
2924
|
+
}
|
|
2925
|
+
end
|
|
2926
|
+
if t == "boolean" or t == "number" or t == "string" then
|
|
2927
|
+
return value
|
|
2928
|
+
end
|
|
2929
|
+
-- Fallback: tostring
|
|
2930
|
+
return tostring(value)
|
|
2931
|
+
end
|
|
2932
|
+
|
|
2933
|
+
local SERIALIZE_SKIP = {
|
|
2934
|
+
Parent = true,
|
|
2935
|
+
ClassName = true,
|
|
2936
|
+
Name = true,
|
|
2937
|
+
Archivable = true,
|
|
2938
|
+
AbsolutePosition = true,
|
|
2939
|
+
AbsoluteSize = true,
|
|
2940
|
+
AbsoluteRotation = true,
|
|
2941
|
+
IsLoaded = true,
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
local function serializeNode(instance, maxDepth, depth)
|
|
2945
|
+
if depth > maxDepth then
|
|
2946
|
+
return nil
|
|
2947
|
+
end
|
|
2948
|
+
|
|
2949
|
+
local node = {
|
|
2950
|
+
ClassName = instance.ClassName,
|
|
2951
|
+
Name = instance.Name,
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
-- Get non-default properties via reflection
|
|
2955
|
+
local ok, propData = pcall(function()
|
|
2956
|
+
return ReflectionService:GetPropertiesOfClass(instance.ClassName)
|
|
2957
|
+
end)
|
|
2958
|
+
|
|
2959
|
+
if ok and propData then
|
|
2960
|
+
-- Create a default instance to compare against
|
|
2961
|
+
local defaultInstance = nil
|
|
2962
|
+
pcall(function()
|
|
2963
|
+
defaultInstance = Instance.new(instance.ClassName)
|
|
2964
|
+
end)
|
|
2965
|
+
|
|
2966
|
+
for _, prop in propData do
|
|
2967
|
+
if not SERIALIZE_SKIP[prop.Name] and not prop.Name:match("^Absolute") then
|
|
2968
|
+
local valOk, value = pcall(function()
|
|
2969
|
+
return instance[prop.Name]
|
|
2970
|
+
end)
|
|
2971
|
+
if valOk and value ~= nil then
|
|
2972
|
+
-- Skip default values
|
|
2973
|
+
local isDefault = false
|
|
2974
|
+
if defaultInstance then
|
|
2975
|
+
local defOk, defVal = pcall(function()
|
|
2976
|
+
return defaultInstance[prop.Name]
|
|
2977
|
+
end)
|
|
2978
|
+
if defOk and defVal == value then
|
|
2979
|
+
isDefault = true
|
|
2980
|
+
end
|
|
2981
|
+
end
|
|
2982
|
+
|
|
2983
|
+
if not isDefault then
|
|
2984
|
+
local jsonVal = valueToJson(value, prop.Type and tostring(prop.Type))
|
|
2985
|
+
if jsonVal ~= nil then
|
|
2986
|
+
node[prop.Name] = jsonVal
|
|
2987
|
+
end
|
|
2988
|
+
end
|
|
2989
|
+
end
|
|
2990
|
+
end
|
|
2991
|
+
end
|
|
2992
|
+
|
|
2993
|
+
if defaultInstance then
|
|
2994
|
+
pcall(function()
|
|
2995
|
+
defaultInstance:Destroy()
|
|
2996
|
+
end)
|
|
2997
|
+
end
|
|
2998
|
+
end
|
|
2999
|
+
|
|
3000
|
+
-- Serialize children
|
|
3001
|
+
local children = instance:GetChildren()
|
|
3002
|
+
if #children > 0 then
|
|
3003
|
+
node.Children = {}
|
|
3004
|
+
for _, child in children do
|
|
3005
|
+
local childNode = serializeNode(child, maxDepth, depth + 1)
|
|
3006
|
+
if childNode then
|
|
3007
|
+
table.insert(node.Children, childNode)
|
|
3008
|
+
end
|
|
3009
|
+
end
|
|
3010
|
+
if #node.Children == 0 then
|
|
3011
|
+
node.Children = nil
|
|
3012
|
+
end
|
|
3013
|
+
end
|
|
3014
|
+
|
|
3015
|
+
return node
|
|
3016
|
+
end
|
|
3017
|
+
|
|
3018
|
+
function UIBuilder.serialize(spec)
|
|
3019
|
+
local path = spec.path
|
|
3020
|
+
if not path then
|
|
3021
|
+
return { success = false, error = "Missing 'path' parameter" }
|
|
3022
|
+
end
|
|
3023
|
+
|
|
3024
|
+
local instance = Explorer.resolveInstance(path)
|
|
3025
|
+
if not instance then
|
|
3026
|
+
return { success = false, error = "Instance not found: " .. path }
|
|
3027
|
+
end
|
|
3028
|
+
|
|
3029
|
+
local maxDepth = spec.maxDepth or 50
|
|
3030
|
+
|
|
3031
|
+
local ok, tree = pcall(function()
|
|
3032
|
+
return serializeNode(instance, maxDepth, 0)
|
|
3033
|
+
end)
|
|
3034
|
+
|
|
3035
|
+
if not ok then
|
|
3036
|
+
return { success = false, error = "Serialization failed: " .. tostring(tree) }
|
|
3037
|
+
end
|
|
3038
|
+
|
|
3039
|
+
return { success = true, tree = tree }
|
|
3040
|
+
end
|
|
3041
|
+
|
|
3042
|
+
local function buildStrictNode(spec, state, depth)
|
|
3043
|
+
assert(type(spec) == "table", "Every UI node must be an object")
|
|
3044
|
+
if depth > state.maxDepth then
|
|
3045
|
+
error("UI tree exceeds maximum depth of " .. state.maxDepth)
|
|
3046
|
+
end
|
|
3047
|
+
state.count += 1
|
|
3048
|
+
if state.count > state.maxNodes then
|
|
3049
|
+
error("UI tree exceeds maximum node count of " .. state.maxNodes)
|
|
3050
|
+
end
|
|
3051
|
+
|
|
3052
|
+
local className = spec.ClassName or spec.className
|
|
3053
|
+
assert(type(className) == "string", "Every UI node requires ClassName")
|
|
3054
|
+
local instance = Instance.new(className)
|
|
3055
|
+
instance.Name = spec.Name or spec.name or className
|
|
3056
|
+
|
|
3057
|
+
for key, value in spec do
|
|
3058
|
+
if not RESERVED_KEYS[key] then
|
|
3059
|
+
ValueCodec.setProperty(instance, key, value)
|
|
3060
|
+
end
|
|
3061
|
+
end
|
|
3062
|
+
local properties = spec.properties or spec.Properties or spec.props or spec.Props
|
|
3063
|
+
if properties ~= nil then
|
|
3064
|
+
assert(type(properties) == "table", "UI node properties must be an object")
|
|
3065
|
+
for key, value in properties do
|
|
3066
|
+
ValueCodec.setProperty(instance, key, value)
|
|
3067
|
+
end
|
|
3068
|
+
end
|
|
3069
|
+
|
|
3070
|
+
local children = spec.Children or spec.children
|
|
3071
|
+
if children ~= nil then
|
|
3072
|
+
assert(type(children) == "table", "UI node Children must be an array")
|
|
3073
|
+
for _, childSpec in children do
|
|
3074
|
+
local child = buildStrictNode(childSpec, state, depth + 1)
|
|
3075
|
+
child.Parent = instance
|
|
3076
|
+
end
|
|
3077
|
+
end
|
|
3078
|
+
return instance
|
|
3079
|
+
end
|
|
3080
|
+
|
|
3081
|
+
function UIBuilder.buildV2(spec)
|
|
3082
|
+
if type(spec.tree) ~= "table" then
|
|
3083
|
+
return { success = false, error = "tree is required" }
|
|
3084
|
+
end
|
|
3085
|
+
local parentRef = spec.parent or { pathSegments = { "StarterGui" } }
|
|
3086
|
+
local parent, parentErr = InstanceRegistry.resolve(parentRef)
|
|
3087
|
+
if not parent then
|
|
3088
|
+
return { success = false, error = "Parent: " .. tostring(parentErr) }
|
|
3089
|
+
end
|
|
3090
|
+
|
|
3091
|
+
local state = {
|
|
3092
|
+
count = 0,
|
|
3093
|
+
maxDepth = math.clamp(spec.maxDepth or 30, 1, 50),
|
|
3094
|
+
maxNodes = math.clamp(spec.maxNodes or 1000, 1, 3000),
|
|
3095
|
+
}
|
|
3096
|
+
local builtOk, rootOrError = pcall(function()
|
|
3097
|
+
return buildStrictNode(spec.tree, state, 0)
|
|
3098
|
+
end)
|
|
3099
|
+
if not builtOk then
|
|
3100
|
+
return { success = false, error = "UI validation failed: " .. tostring(rootOrError) }
|
|
3101
|
+
end
|
|
3102
|
+
local root = rootOrError
|
|
3103
|
+
|
|
3104
|
+
local existing = nil
|
|
3105
|
+
local duplicateCount = 0
|
|
3106
|
+
for _, child in parent:GetChildren() do
|
|
3107
|
+
if child.Name == root.Name then
|
|
3108
|
+
existing = child
|
|
3109
|
+
duplicateCount += 1
|
|
3110
|
+
end
|
|
3111
|
+
end
|
|
3112
|
+
if duplicateCount > 1 then
|
|
3113
|
+
root:Destroy()
|
|
3114
|
+
return {
|
|
3115
|
+
success = false,
|
|
3116
|
+
error = "Replacement target is ambiguous because multiple children share the name " .. root.Name,
|
|
3117
|
+
}
|
|
3118
|
+
end
|
|
3119
|
+
if existing and spec.replaceExisting ~= true then
|
|
3120
|
+
root:Destroy()
|
|
3121
|
+
return {
|
|
3122
|
+
success = false,
|
|
3123
|
+
error = "A child named " .. root.Name .. " already exists; set replaceExisting=true to replace it",
|
|
3124
|
+
}
|
|
3125
|
+
end
|
|
3126
|
+
|
|
3127
|
+
local recording = ChangeHistoryService:TryBeginRecording("Dominus 2: Build UI " .. root.Name)
|
|
3128
|
+
if not recording then
|
|
3129
|
+
root:Destroy()
|
|
3130
|
+
return { success = false, error = "Another plugin recording is already active" }
|
|
3131
|
+
end
|
|
3132
|
+
local commitOk, commitErr = pcall(function()
|
|
3133
|
+
if existing then
|
|
3134
|
+
existing:Destroy()
|
|
3135
|
+
end
|
|
3136
|
+
root.Parent = parent
|
|
3137
|
+
end)
|
|
3138
|
+
if not commitOk then
|
|
3139
|
+
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
|
|
3140
|
+
if root.Parent == nil then
|
|
3141
|
+
root:Destroy()
|
|
3142
|
+
end
|
|
3143
|
+
return { success = false, error = "UI commit failed: " .. tostring(commitErr), rolledBack = true }
|
|
3144
|
+
end
|
|
3145
|
+
ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
|
|
3146
|
+
return {
|
|
3147
|
+
success = true,
|
|
3148
|
+
root = InstanceRegistry.toRef(root),
|
|
3149
|
+
instanceCount = state.count,
|
|
3150
|
+
replaced = existing ~= nil,
|
|
3151
|
+
}
|
|
3152
|
+
end
|
|
3153
|
+
|
|
3154
|
+
function UIBuilder.snapshotV2(spec)
|
|
3155
|
+
local instance, err = InstanceRegistry.resolve(spec.target)
|
|
3156
|
+
if not instance then
|
|
3157
|
+
return { success = false, error = err }
|
|
3158
|
+
end
|
|
3159
|
+
local maxDepth = math.clamp(spec.maxDepth or 30, 0, 50)
|
|
3160
|
+
local ok, tree = pcall(function()
|
|
3161
|
+
return serializeNode(instance, maxDepth, 0)
|
|
3162
|
+
end)
|
|
3163
|
+
if not ok then
|
|
3164
|
+
return { success = false, error = "UI serialization failed: " .. tostring(tree) }
|
|
3165
|
+
end
|
|
3166
|
+
return { success = true, target = InstanceRegistry.toRef(instance), tree = tree }
|
|
3167
|
+
end
|
|
3168
|
+
|
|
3169
|
+
return UIBuilder
|
|
3170
|
+
]]></string>
|
|
3171
|
+
</Properties>
|
|
3172
|
+
</Item>
|
|
3173
|
+
<Item class="ModuleScript" referent="11">
|
|
3174
|
+
<Properties>
|
|
3175
|
+
<string name="Name">ValueCodec</string>
|
|
3176
|
+
<string name="Source"><![CDATA[local InstanceRegistry = require(script.Parent.InstanceRegistry)
|
|
3177
|
+
|
|
3178
|
+
local ValueCodec = {}
|
|
3179
|
+
|
|
3180
|
+
local function number(value, label)
|
|
3181
|
+
if type(value) ~= "number" then
|
|
3182
|
+
error(label .. " must be a number")
|
|
3183
|
+
end
|
|
3184
|
+
return value
|
|
3185
|
+
end
|
|
3186
|
+
|
|
3187
|
+
local function component(value, names, index, default)
|
|
3188
|
+
for _, name in names do
|
|
3189
|
+
if value[name] ~= nil then
|
|
3190
|
+
return number(value[name], name)
|
|
3191
|
+
end
|
|
3192
|
+
end
|
|
3193
|
+
if value[index] ~= nil then
|
|
3194
|
+
return number(value[index], tostring(index))
|
|
3195
|
+
end
|
|
3196
|
+
return default
|
|
3197
|
+
end
|
|
3198
|
+
|
|
3199
|
+
local function decodeColor3(value)
|
|
3200
|
+
if type(value) == "string" then
|
|
3201
|
+
if value:sub(1, 1) == "#" then
|
|
3202
|
+
return Color3.fromHex(value)
|
|
3203
|
+
end
|
|
3204
|
+
local r, g, b = value:match("rgb%s*%((%d+)%D+(%d+)%D+(%d+)%)")
|
|
3205
|
+
if r then
|
|
3206
|
+
return Color3.fromRGB(tonumber(r), tonumber(g), tonumber(b))
|
|
3207
|
+
end
|
|
3208
|
+
error("Color3 strings must be #RRGGBB or rgb(r,g,b)")
|
|
3209
|
+
end
|
|
3210
|
+
if type(value) ~= "table" then
|
|
3211
|
+
error("Color3 must be a string or array")
|
|
3212
|
+
end
|
|
3213
|
+
local r = component(value, { "R", "r", "red" }, 1, 0)
|
|
3214
|
+
local g = component(value, { "G", "g", "green" }, 2, 0)
|
|
3215
|
+
local b = component(value, { "B", "b", "blue" }, 3, 0)
|
|
3216
|
+
if r > 1 or g > 1 or b > 1 then
|
|
3217
|
+
return Color3.fromRGB(r, g, b)
|
|
3218
|
+
end
|
|
3219
|
+
return Color3.new(r, g, b)
|
|
3220
|
+
end
|
|
3221
|
+
|
|
3222
|
+
local function decodeEnum(current, value)
|
|
3223
|
+
if typeof(value) == "EnumItem" then
|
|
3224
|
+
return value
|
|
3225
|
+
end
|
|
3226
|
+
if type(value) ~= "string" then
|
|
3227
|
+
error("Enum value must be a string")
|
|
3228
|
+
end
|
|
3229
|
+
local itemName = value:match("Enum%.[^.]+%.(.+)") or value
|
|
3230
|
+
for _, item in current.EnumType:GetEnumItems() do
|
|
3231
|
+
if item.Name == itemName then
|
|
3232
|
+
return item
|
|
3233
|
+
end
|
|
3234
|
+
end
|
|
3235
|
+
error("Invalid enum value " .. value .. " for " .. tostring(current.EnumType))
|
|
3236
|
+
end
|
|
3237
|
+
|
|
3238
|
+
function ValueCodec.decodeForCurrent(current, value)
|
|
3239
|
+
local expectedType = typeof(current)
|
|
3240
|
+
if expectedType == "UDim2" then
|
|
3241
|
+
assert(type(value) == "table", "UDim2 must be an array or object")
|
|
3242
|
+
return UDim2.new(
|
|
3243
|
+
component(value, { "XScale", "xScale" }, 1, 0),
|
|
3244
|
+
component(value, { "XOffset", "xOffset" }, 2, 0),
|
|
3245
|
+
component(value, { "YScale", "yScale" }, 3, 0),
|
|
3246
|
+
component(value, { "YOffset", "yOffset" }, 4, 0)
|
|
3247
|
+
)
|
|
3248
|
+
elseif expectedType == "UDim" then
|
|
3249
|
+
assert(type(value) == "table", "UDim must be an array or object")
|
|
3250
|
+
return UDim.new(component(value, { "Scale", "scale" }, 1, 0), component(value, { "Offset", "offset" }, 2, 0))
|
|
3251
|
+
elseif expectedType == "Vector2" then
|
|
3252
|
+
assert(type(value) == "table", "Vector2 must be an array or object")
|
|
3253
|
+
return Vector2.new(component(value, { "X", "x" }, 1, 0), component(value, { "Y", "y" }, 2, 0))
|
|
3254
|
+
elseif expectedType == "Vector3" then
|
|
3255
|
+
assert(type(value) == "table", "Vector3 must be an array or object")
|
|
3256
|
+
return Vector3.new(
|
|
3257
|
+
component(value, { "X", "x" }, 1, 0),
|
|
3258
|
+
component(value, { "Y", "y" }, 2, 0),
|
|
3259
|
+
component(value, { "Z", "z" }, 3, 0)
|
|
3260
|
+
)
|
|
3261
|
+
elseif expectedType == "Color3" then
|
|
3262
|
+
return decodeColor3(value)
|
|
3263
|
+
elseif expectedType == "BrickColor" then
|
|
3264
|
+
return BrickColor.new(value)
|
|
3265
|
+
elseif expectedType == "EnumItem" then
|
|
3266
|
+
return decodeEnum(current, value)
|
|
3267
|
+
elseif expectedType == "Rect" then
|
|
3268
|
+
assert(type(value) == "table", "Rect must be a four-number array")
|
|
3269
|
+
return Rect.new(number(value[1], "1"), number(value[2], "2"), number(value[3], "3"), number(value[4], "4"))
|
|
3270
|
+
elseif expectedType == "NumberRange" then
|
|
3271
|
+
if type(value) == "number" then
|
|
3272
|
+
return NumberRange.new(value)
|
|
3273
|
+
end
|
|
3274
|
+
assert(type(value) == "table", "NumberRange must be a number or two-number array")
|
|
3275
|
+
return NumberRange.new(number(value[1], "1"), number(value[2] or value[1], "2"))
|
|
3276
|
+
elseif expectedType == "CFrame" then
|
|
3277
|
+
assert(type(value) == "table", "CFrame must be an array")
|
|
3278
|
+
if #value == 3 then
|
|
3279
|
+
return CFrame.new(value[1], value[2], value[3])
|
|
3280
|
+
elseif #value == 12 then
|
|
3281
|
+
return CFrame.new(table.unpack(value))
|
|
3282
|
+
end
|
|
3283
|
+
error("CFrame requires 3 or 12 numbers")
|
|
3284
|
+
elseif expectedType == "NumberSequence" then
|
|
3285
|
+
if type(value) == "number" then
|
|
3286
|
+
return NumberSequence.new(value)
|
|
3287
|
+
end
|
|
3288
|
+
assert(type(value) == "table", "NumberSequence must be a number or keypoint array")
|
|
3289
|
+
local keypoints = {}
|
|
3290
|
+
for _, keypoint in value do
|
|
3291
|
+
table.insert(keypoints, NumberSequenceKeypoint.new(keypoint[1], keypoint[2], keypoint[3] or 0))
|
|
3292
|
+
end
|
|
3293
|
+
return NumberSequence.new(keypoints)
|
|
3294
|
+
elseif expectedType == "ColorSequence" then
|
|
3295
|
+
if type(value) == "string" then
|
|
3296
|
+
return ColorSequence.new(decodeColor3(value))
|
|
3297
|
+
end
|
|
3298
|
+
assert(type(value) == "table", "ColorSequence must be a color or keypoint array")
|
|
3299
|
+
local keypoints = {}
|
|
3300
|
+
for _, keypoint in value do
|
|
3301
|
+
table.insert(keypoints, ColorSequenceKeypoint.new(keypoint[1], decodeColor3(keypoint[2])))
|
|
3302
|
+
end
|
|
3303
|
+
return ColorSequence.new(keypoints)
|
|
3304
|
+
elseif expectedType == "Font" then
|
|
3305
|
+
assert(type(value) == "table", "Font must be an object")
|
|
3306
|
+
local weight = Enum.FontWeight[value.Weight or value.weight or "Regular"]
|
|
3307
|
+
local style = Enum.FontStyle[value.Style or value.style or "Normal"]
|
|
3308
|
+
assert(weight and style, "Invalid Font weight or style")
|
|
3309
|
+
return Font.new(value.Family or value.family, weight, style)
|
|
3310
|
+
elseif expectedType == "Instance" then
|
|
3311
|
+
local resolved, err = InstanceRegistry.resolve(value)
|
|
3312
|
+
if not resolved then
|
|
3313
|
+
error(err)
|
|
3314
|
+
end
|
|
3315
|
+
return resolved
|
|
3316
|
+
elseif expectedType == "number" then
|
|
3317
|
+
return number(value, "Property")
|
|
3318
|
+
elseif expectedType == "boolean" then
|
|
3319
|
+
assert(type(value) == "boolean", "Property must be a boolean")
|
|
3320
|
+
return value
|
|
3321
|
+
elseif expectedType == "string" then
|
|
3322
|
+
assert(type(value) == "string", "Property must be a string")
|
|
3323
|
+
return value
|
|
3324
|
+
end
|
|
3325
|
+
return value
|
|
3326
|
+
end
|
|
3327
|
+
|
|
3328
|
+
function ValueCodec.encode(value)
|
|
3329
|
+
local valueType = typeof(value)
|
|
3330
|
+
if valueType == "UDim2" then
|
|
3331
|
+
return { value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset }
|
|
3332
|
+
end
|
|
3333
|
+
if valueType == "UDim" then
|
|
3334
|
+
return { value.Scale, value.Offset }
|
|
3335
|
+
end
|
|
3336
|
+
if valueType == "Vector2" then
|
|
3337
|
+
return { value.X, value.Y }
|
|
3338
|
+
end
|
|
3339
|
+
if valueType == "Vector3" then
|
|
3340
|
+
return { value.X, value.Y, value.Z }
|
|
3341
|
+
end
|
|
3342
|
+
if valueType == "Color3" then
|
|
3343
|
+
return "#" .. value:ToHex()
|
|
3344
|
+
end
|
|
3345
|
+
if valueType == "BrickColor" then
|
|
3346
|
+
return value.Name
|
|
3347
|
+
end
|
|
3348
|
+
if valueType == "EnumItem" then
|
|
3349
|
+
return tostring(value)
|
|
3350
|
+
end
|
|
3351
|
+
if valueType == "Rect" then
|
|
3352
|
+
return { value.Min.X, value.Min.Y, value.Max.X, value.Max.Y }
|
|
3353
|
+
end
|
|
3354
|
+
if valueType == "NumberRange" then
|
|
3355
|
+
return { value.Min, value.Max }
|
|
3356
|
+
end
|
|
3357
|
+
if valueType == "CFrame" then
|
|
3358
|
+
return { value:GetComponents() }
|
|
3359
|
+
end
|
|
3360
|
+
if valueType == "NumberSequence" then
|
|
3361
|
+
local result = {}
|
|
3362
|
+
for _, keypoint in value.Keypoints do
|
|
3363
|
+
table.insert(result, { keypoint.Time, keypoint.Value, keypoint.Envelope })
|
|
3364
|
+
end
|
|
3365
|
+
return result
|
|
3366
|
+
end
|
|
3367
|
+
if valueType == "ColorSequence" then
|
|
3368
|
+
local result = {}
|
|
3369
|
+
for _, keypoint in value.Keypoints do
|
|
3370
|
+
table.insert(result, { keypoint.Time, "#" .. keypoint.Value:ToHex() })
|
|
3371
|
+
end
|
|
3372
|
+
return result
|
|
3373
|
+
end
|
|
3374
|
+
if valueType == "Font" then
|
|
3375
|
+
return { Family = value.Family, Weight = value.Weight.Name, Style = value.Style.Name }
|
|
3376
|
+
end
|
|
3377
|
+
if valueType == "Instance" then
|
|
3378
|
+
return InstanceRegistry.toRef(value)
|
|
3379
|
+
end
|
|
3380
|
+
if valueType == "boolean" or valueType == "number" or valueType == "string" then
|
|
3381
|
+
return value
|
|
3382
|
+
end
|
|
3383
|
+
return tostring(value)
|
|
3384
|
+
end
|
|
3385
|
+
|
|
3386
|
+
function ValueCodec.setProperty(instance, propertyName, value)
|
|
3387
|
+
if propertyName == "Parent" or propertyName == "ClassName" or propertyName == "Source" then
|
|
3388
|
+
error("Property cannot be changed through setProperties: " .. propertyName)
|
|
3389
|
+
end
|
|
3390
|
+
local readOk, current = pcall(function()
|
|
3391
|
+
return instance[propertyName]
|
|
3392
|
+
end)
|
|
3393
|
+
if not readOk then
|
|
3394
|
+
error("Unknown or unreadable property: " .. propertyName)
|
|
3395
|
+
end
|
|
3396
|
+
local decoded = ValueCodec.decodeForCurrent(current, value)
|
|
3397
|
+
local writeOk, writeErr = pcall(function()
|
|
3398
|
+
instance[propertyName] = decoded
|
|
3399
|
+
end)
|
|
3400
|
+
if not writeOk then
|
|
3401
|
+
error("Failed to set " .. propertyName .. ": " .. tostring(writeErr))
|
|
3402
|
+
end
|
|
3403
|
+
return ValueCodec.encode(instance[propertyName])
|
|
3404
|
+
end
|
|
3405
|
+
|
|
3406
|
+
return ValueCodec
|
|
3407
|
+
]]></string>
|
|
3408
|
+
</Properties>
|
|
3409
|
+
</Item>
|
|
3410
|
+
<Item class="ModuleScript" referent="12">
|
|
3411
|
+
<Properties>
|
|
3412
|
+
<string name="Name">Watcher</string>
|
|
3413
|
+
<string name="Source"><![CDATA[--[[
|
|
3414
|
+
Watcher: monitors LogService output and forwards to CLI
|
|
3415
|
+
]]
|
|
3416
|
+
|
|
3417
|
+
local LogService = game:GetService("LogService")
|
|
3418
|
+
|
|
3419
|
+
local Watcher = {}
|
|
3420
|
+
|
|
3421
|
+
local outputBuffer = {}
|
|
3422
|
+
local MAX_BUFFER = 500
|
|
3423
|
+
local connection = nil
|
|
3424
|
+
local outputCallback = nil
|
|
3425
|
+
|
|
3426
|
+
local function classifyLevel(messageType)
|
|
3427
|
+
if messageType == Enum.MessageType.MessageOutput then
|
|
3428
|
+
return "info"
|
|
3429
|
+
elseif messageType == Enum.MessageType.MessageWarning then
|
|
3430
|
+
return "warn"
|
|
3431
|
+
elseif messageType == Enum.MessageType.MessageError then
|
|
3432
|
+
return "error"
|
|
3433
|
+
end
|
|
3434
|
+
return "info"
|
|
3435
|
+
end
|
|
3436
|
+
|
|
3437
|
+
function Watcher.start(callback)
|
|
3438
|
+
outputCallback = callback
|
|
3439
|
+
|
|
3440
|
+
if connection then
|
|
3441
|
+
connection:Disconnect()
|
|
3442
|
+
end
|
|
3443
|
+
|
|
3444
|
+
connection = LogService.MessageOut:Connect(function(message, messageType)
|
|
3445
|
+
-- Skip every Dominus-prefixed message, including transport errors.
|
|
3446
|
+
if string.find(message, "[Dominus", 1, true) then
|
|
3447
|
+
return
|
|
3448
|
+
end
|
|
3449
|
+
|
|
3450
|
+
local entry = {
|
|
3451
|
+
message = message,
|
|
3452
|
+
level = classifyLevel(messageType),
|
|
3453
|
+
timestamp = os.clock(),
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
-- Buffer for get_output requests
|
|
3457
|
+
table.insert(outputBuffer, entry)
|
|
3458
|
+
if #outputBuffer > MAX_BUFFER then
|
|
3459
|
+
table.remove(outputBuffer, 1)
|
|
3460
|
+
end
|
|
3461
|
+
|
|
3462
|
+
-- Forward to CLI
|
|
3463
|
+
if outputCallback then
|
|
3464
|
+
outputCallback(entry)
|
|
3465
|
+
end
|
|
3466
|
+
end)
|
|
3467
|
+
end
|
|
3468
|
+
|
|
3469
|
+
function Watcher.stop()
|
|
3470
|
+
if connection then
|
|
3471
|
+
connection:Disconnect()
|
|
3472
|
+
connection = nil
|
|
3473
|
+
end
|
|
3474
|
+
outputCallback = nil
|
|
3475
|
+
end
|
|
3476
|
+
|
|
3477
|
+
function Watcher.getRecentOutput(limit, levelFilter)
|
|
3478
|
+
limit = limit or 50
|
|
3479
|
+
local result = {}
|
|
3480
|
+
|
|
3481
|
+
for i = math.max(1, #outputBuffer - limit + 1), #outputBuffer do
|
|
3482
|
+
local entry = outputBuffer[i]
|
|
3483
|
+
if entry then
|
|
3484
|
+
if not levelFilter or entry.level == levelFilter then
|
|
3485
|
+
table.insert(result, entry)
|
|
3486
|
+
end
|
|
3487
|
+
end
|
|
3488
|
+
end
|
|
3489
|
+
|
|
3490
|
+
return { entries = result }
|
|
3491
|
+
end
|
|
3492
|
+
|
|
3493
|
+
function Watcher.clearBuffer()
|
|
3494
|
+
outputBuffer = {}
|
|
3495
|
+
end
|
|
3496
|
+
|
|
3497
|
+
return Watcher
|
|
3498
|
+
]]></string>
|
|
3499
|
+
</Properties>
|
|
3500
|
+
</Item>
|
|
3501
|
+
<Item class="ModuleScript" referent="13">
|
|
3502
|
+
<Properties>
|
|
3503
|
+
<string name="Name">WsClient</string>
|
|
3504
|
+
<string name="Source"><![CDATA[local HttpService = game:GetService("HttpService")
|
|
3505
|
+
|
|
3506
|
+
local WsClient = {}
|
|
3507
|
+
|
|
3508
|
+
local currentConnection = nil
|
|
3509
|
+
local currentSignals = {}
|
|
3510
|
+
local currentCancel = nil
|
|
3511
|
+
local generation = 0
|
|
3512
|
+
|
|
3513
|
+
local function disconnectSignals()
|
|
3514
|
+
for _, signal in currentSignals do
|
|
3515
|
+
signal:Disconnect()
|
|
3516
|
+
end
|
|
3517
|
+
currentSignals = {}
|
|
3518
|
+
end
|
|
3519
|
+
|
|
3520
|
+
local function closeCandidate(candidate)
|
|
3521
|
+
pcall(function()
|
|
3522
|
+
candidate:Close()
|
|
3523
|
+
end)
|
|
3524
|
+
end
|
|
3525
|
+
|
|
3526
|
+
function WsClient.connect(url, callbacks, timeoutSeconds)
|
|
3527
|
+
WsClient.disconnect()
|
|
3528
|
+
generation += 1
|
|
3529
|
+
local attempt = generation
|
|
3530
|
+
local completion = Instance.new("BindableEvent")
|
|
3531
|
+
local completed = false
|
|
3532
|
+
local opened = false
|
|
3533
|
+
|
|
3534
|
+
local ok, candidateOrError = pcall(function()
|
|
3535
|
+
return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, { Url = url })
|
|
3536
|
+
end)
|
|
3537
|
+
if not ok then
|
|
3538
|
+
completion:Destroy()
|
|
3539
|
+
if callbacks.onError then
|
|
3540
|
+
callbacks.onError(0, tostring(candidateOrError))
|
|
3541
|
+
end
|
|
3542
|
+
return false, tostring(candidateOrError)
|
|
3543
|
+
end
|
|
3544
|
+
local candidate = candidateOrError
|
|
3545
|
+
|
|
3546
|
+
local function finish(success, reason)
|
|
3547
|
+
if completed then
|
|
3548
|
+
return
|
|
3549
|
+
end
|
|
3550
|
+
completed = true
|
|
3551
|
+
completion:Fire(success, reason)
|
|
3552
|
+
end
|
|
3553
|
+
local function cancelPending()
|
|
3554
|
+
finish(false, "Connection cancelled")
|
|
3555
|
+
end
|
|
3556
|
+
currentCancel = cancelPending
|
|
3557
|
+
|
|
3558
|
+
table.insert(
|
|
3559
|
+
currentSignals,
|
|
3560
|
+
candidate.Opened:Connect(function(statusCode, headers)
|
|
3561
|
+
if attempt ~= generation then
|
|
3562
|
+
return
|
|
3563
|
+
end
|
|
3564
|
+
opened = true
|
|
3565
|
+
currentConnection = candidate
|
|
3566
|
+
if callbacks.onOpen then
|
|
3567
|
+
callbacks.onOpen(statusCode, headers)
|
|
3568
|
+
end
|
|
3569
|
+
finish(true)
|
|
3570
|
+
end)
|
|
3571
|
+
)
|
|
3572
|
+
|
|
3573
|
+
table.insert(
|
|
3574
|
+
currentSignals,
|
|
3575
|
+
candidate.MessageReceived:Connect(function(message)
|
|
3576
|
+
if attempt == generation and opened and callbacks.onMessage then
|
|
3577
|
+
callbacks.onMessage(message)
|
|
3578
|
+
end
|
|
3579
|
+
end)
|
|
3580
|
+
)
|
|
3581
|
+
|
|
3582
|
+
table.insert(
|
|
3583
|
+
currentSignals,
|
|
3584
|
+
candidate.Error:Connect(function(statusCode, errorMessage)
|
|
3585
|
+
if attempt ~= generation then
|
|
3586
|
+
return
|
|
3587
|
+
end
|
|
3588
|
+
if callbacks.onError then
|
|
3589
|
+
callbacks.onError(statusCode, errorMessage)
|
|
3590
|
+
end
|
|
3591
|
+
if not opened then
|
|
3592
|
+
finish(false, errorMessage)
|
|
3593
|
+
end
|
|
3594
|
+
end)
|
|
3595
|
+
)
|
|
3596
|
+
|
|
3597
|
+
table.insert(
|
|
3598
|
+
currentSignals,
|
|
3599
|
+
candidate.Closed:Connect(function()
|
|
3600
|
+
if attempt ~= generation then
|
|
3601
|
+
return
|
|
3602
|
+
end
|
|
3603
|
+
local wasOpened = opened
|
|
3604
|
+
currentConnection = nil
|
|
3605
|
+
opened = false
|
|
3606
|
+
if callbacks.onClose then
|
|
3607
|
+
callbacks.onClose()
|
|
3608
|
+
end
|
|
3609
|
+
if not wasOpened then
|
|
3610
|
+
finish(false, "Connection closed before opening")
|
|
3611
|
+
end
|
|
3612
|
+
end)
|
|
3613
|
+
)
|
|
3614
|
+
|
|
3615
|
+
task.delay(timeoutSeconds or 4, function()
|
|
3616
|
+
if attempt == generation and not completed then
|
|
3617
|
+
closeCandidate(candidate)
|
|
3618
|
+
finish(false, "Connection timed out")
|
|
3619
|
+
end
|
|
3620
|
+
end)
|
|
3621
|
+
|
|
3622
|
+
local success, reason = completion.Event:Wait()
|
|
3623
|
+
if currentCancel == cancelPending then
|
|
3624
|
+
currentCancel = nil
|
|
3625
|
+
end
|
|
3626
|
+
completion:Destroy()
|
|
3627
|
+
if not success then
|
|
3628
|
+
closeCandidate(candidate)
|
|
3629
|
+
if attempt == generation then
|
|
3630
|
+
currentConnection = nil
|
|
3631
|
+
disconnectSignals()
|
|
3632
|
+
end
|
|
3633
|
+
end
|
|
3634
|
+
return success, reason
|
|
3635
|
+
end
|
|
3636
|
+
|
|
3637
|
+
function WsClient.send(message)
|
|
3638
|
+
if not currentConnection then
|
|
3639
|
+
return false, "WebSocket is not open"
|
|
3640
|
+
end
|
|
3641
|
+
local ok, err = pcall(function()
|
|
3642
|
+
currentConnection:Send(message)
|
|
3643
|
+
end)
|
|
3644
|
+
if not ok then
|
|
3645
|
+
return false, tostring(err)
|
|
3646
|
+
end
|
|
3647
|
+
return true
|
|
3648
|
+
end
|
|
3649
|
+
|
|
3650
|
+
function WsClient.disconnect()
|
|
3651
|
+
generation += 1
|
|
3652
|
+
local cancelPending = currentCancel
|
|
3653
|
+
currentCancel = nil
|
|
3654
|
+
if cancelPending then
|
|
3655
|
+
cancelPending()
|
|
3656
|
+
end
|
|
3657
|
+
local connection = currentConnection
|
|
3658
|
+
currentConnection = nil
|
|
3659
|
+
disconnectSignals()
|
|
3660
|
+
if connection then
|
|
3661
|
+
closeCandidate(connection)
|
|
3662
|
+
end
|
|
3663
|
+
end
|
|
3664
|
+
|
|
3665
|
+
function WsClient.isConnected()
|
|
3666
|
+
return currentConnection ~= nil
|
|
3667
|
+
end
|
|
3668
|
+
|
|
3669
|
+
return WsClient
|
|
3670
|
+
]]></string>
|
|
3671
|
+
</Properties>
|
|
3672
|
+
</Item>
|
|
3673
|
+
</Item>
|
|
3674
|
+
</roblox>
|