dominus-cli 2.0.0 → 2.1.1

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.
@@ -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" 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>