@adiholick/nvstudio-mcp 2.2.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,229 @@
10
10
  <int64 name="SourceAssetId">-1</int64>
11
11
  <BinaryString name="Tags"></BinaryString>
12
12
  </Properties>
13
+ <Item class="ModuleScript" referent="RBX_batch_create_instances">
14
+ <Properties>
15
+ <BinaryString name="AttributesSerialize"></BinaryString>
16
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
17
+ <bool name="DefinesCapabilities">false</bool>
18
+ <bool name="Disabled">false</bool>
19
+ <Content name="LinkedSource"><null></null></Content>
20
+ <string name="Name">batch_create_instances</string>
21
+ <int64 name="SourceAssetId">-1</int64>
22
+ <BinaryString name="Tags"></BinaryString>
23
+ <ProtectedString name="Source"><![CDATA[--!strict
24
+ -- ============================================================================
25
+ -- Skill Module: batch_create_instances
26
+ -- Deskripsi: Pembangun Hierarki Instance Massal & Deklaratif (Tree / Batch List)
27
+ -- ============================================================================
28
+
29
+ local HttpService = game:GetService("HttpService")
30
+
31
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
32
+ local success, config = pcall(function()
33
+ if type(data) == "table" then return data end
34
+ return HttpService:JSONDecode(tostring(data))
35
+ end)
36
+
37
+ if not success or type(config) ~= "table" then
38
+ return { status = "error", error = "Format data JSON tidak valid untuk batch_create_instances." }
39
+ end
40
+
41
+ local setPropsFunc = require(script.Parent:WaitForChild("set_properties"))
42
+ local defaultParent = targetInstance or game.Workspace
43
+
44
+ if config.target and ctx and ctx.getInstanceFromPath then
45
+ local resolved = ctx.getInstanceFromPath(config.target)
46
+ if resolved then defaultParent = resolved end
47
+ end
48
+
49
+ local createdList = {}
50
+ local errors = {}
51
+ local createdCount = 0
52
+
53
+ local function applyProps(inst: Instance, props: any)
54
+ if not props or type(props) ~= "table" or not next(props) then return end
55
+ local propJson = HttpService:JSONEncode(props)
56
+ local res = setPropsFunc(inst, propJson, ctx, inst:GetFullName())
57
+ if res and res.status == "error" then
58
+ table.insert(errors, string.format("[%s] Error properti: %s", inst.Name, tostring(res.result or res.error)))
59
+ end
60
+ end
61
+
62
+ -- 1. MODE HIERARCHICAL TREE (Pohon Bersarang)
63
+ local function buildTreeNode(parentInst: Instance, node: any): Instance?
64
+ local className = node.className or node.ClassName
65
+ if not className or type(className) ~= "string" then
66
+ table.insert(errors, "Node pohon tidak memiliki ClassName yang valid.")
67
+ return nil
68
+ end
69
+
70
+ local ok, inst = pcall(function()
71
+ local newObj = Instance.new(className)
72
+ if node.name or node.Name then
73
+ newObj.Name = tostring(node.name or node.Name)
74
+ end
75
+ newObj.Parent = parentInst
76
+ return newObj
77
+ end)
78
+
79
+ if not ok or not inst then
80
+ table.insert(errors, string.format("Gagal membuat instance '%s': %s", className, tostring(inst)))
81
+ return nil
82
+ end
83
+
84
+ createdCount = createdCount + 1
85
+ table.insert(createdList, inst:GetFullName())
86
+
87
+ -- Terapkan properti
88
+ applyProps(inst, node.properties or node.Properties)
89
+
90
+ -- Rekursif untuk seluruh child
91
+ local children = node.children or node.Children
92
+ if children and type(children) == "table" then
93
+ for _, childNode in ipairs(children) do
94
+ buildTreeNode(inst, childNode)
95
+ end
96
+ end
97
+
98
+ return inst
99
+ end
100
+
101
+ -- Eksekusi Mode Tree jika disediakan
102
+ if config.tree and type(config.tree) == "table" then
103
+ buildTreeNode(defaultParent, config.tree)
104
+ end
105
+
106
+ -- 2. MODE FLAT ARRAY (Daftar Instance Terpisah)
107
+ local instancesArray = config.instances or config.items
108
+ if instancesArray and type(instancesArray) == "table" then
109
+ for idx, item in ipairs(instancesArray) do
110
+ local parentInst = defaultParent
111
+ if item.parent and ctx and ctx.getInstanceFromPath then
112
+ local resParent = ctx.getInstanceFromPath(item.parent)
113
+ if resParent then parentInst = resParent end
114
+ end
115
+
116
+ local className = item.className or item.ClassName
117
+ if className then
118
+ local ok, inst = pcall(function()
119
+ local newObj = Instance.new(className)
120
+ if item.name or item.Name then
121
+ newObj.Name = tostring(item.name or item.Name)
122
+ end
123
+ newObj.Parent = parentInst
124
+ return newObj
125
+ end)
126
+
127
+ if ok and inst then
128
+ createdCount = createdCount + 1
129
+ table.insert(createdList, inst:GetFullName())
130
+ applyProps(inst, item.properties or item.Properties)
131
+ else
132
+ table.insert(errors, string.format("Item #%d (%s) gagal dibuat: %s", idx, tostring(className), tostring(inst)))
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ local status = (createdCount > 0 or #errors == 0) and "success" or "error"
139
+ return {
140
+ status = status,
141
+ result = {
142
+ totalCreated = createdCount,
143
+ created = createdList,
144
+ hasErrors = (#errors > 0),
145
+ errors = errors,
146
+ message = string.format("Berhasil membuat %d instance secara batch.", createdCount)
147
+ }
148
+ }
149
+ end
150
+
151
+ ]]></ProtectedString>
152
+ </Properties>
153
+ </Item>
154
+ <Item class="ModuleScript" referent="RBX_batch_set_properties">
155
+ <Properties>
156
+ <BinaryString name="AttributesSerialize"></BinaryString>
157
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
158
+ <bool name="DefinesCapabilities">false</bool>
159
+ <bool name="Disabled">false</bool>
160
+ <Content name="LinkedSource"><null></null></Content>
161
+ <string name="Name">batch_set_properties</string>
162
+ <int64 name="SourceAssetId">-1</int64>
163
+ <BinaryString name="Tags"></BinaryString>
164
+ <ProtectedString name="Source"><![CDATA[--!strict
165
+ -- ============================================================================
166
+ -- Skill Module: batch_set_properties
167
+ -- Deskripsi: Modifikasi Properti Massal untuk Beberapa Instance Sekaligus
168
+ -- ============================================================================
169
+
170
+ local HttpService = game:GetService("HttpService")
171
+
172
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
173
+ local success, config = pcall(function()
174
+ if type(data) == "table" then return data end
175
+ return HttpService:JSONDecode(tostring(data))
176
+ end)
177
+
178
+ if not success or type(config) ~= "table" then
179
+ return { status = "error", error = "Format data JSON tidak valid untuk batch_set_properties." }
180
+ end
181
+
182
+ local setPropsFunc = require(script.Parent:WaitForChild("set_properties"))
183
+ local updates = config.updates or config
184
+ if type(updates) ~= "table" then
185
+ return { status = "error", error = "Parameter 'updates' harus berupa array pembaruan properti." }
186
+ end
187
+
188
+ local updatedCount = 0
189
+ local successfulTargets = {}
190
+ local errors = {}
191
+
192
+ for idx, updateItem in ipairs(updates) do
193
+ local inst = targetInstance
194
+ local instPath = updateItem.target or updateItem.path
195
+ if instPath and ctx and ctx.getInstanceFromPath then
196
+ local resolved = ctx.getInstanceFromPath(instPath)
197
+ if resolved then
198
+ inst = resolved
199
+ else
200
+ table.insert(errors, string.format("Item #%d: Target path '%s' tidak ditemukan.", idx, tostring(instPath)))
201
+ continue
202
+ end
203
+ end
204
+
205
+ local props = updateItem.properties or updateItem.props
206
+ if inst and props and type(props) == "table" then
207
+ local propJson = HttpService:JSONEncode(props)
208
+ local res = setPropsFunc(inst, propJson, ctx, inst:GetFullName())
209
+ if res and res.status == "error" then
210
+ table.insert(errors, string.format("[%s]: %s", inst.Name, tostring(res.result or res.error)))
211
+ else
212
+ updatedCount = updatedCount + 1
213
+ table.insert(successfulTargets, inst:GetFullName())
214
+ end
215
+ else
216
+ table.insert(errors, string.format("Item #%d: Instance atau properties tidak valid.", idx))
217
+ end
218
+ end
219
+
220
+ local status = (updatedCount > 0 or #errors == 0) and "success" or "error"
221
+ return {
222
+ status = status,
223
+ result = {
224
+ totalUpdated = updatedCount,
225
+ updatedInstances = successfulTargets,
226
+ hasErrors = (#errors > 0),
227
+ errors = errors,
228
+ message = string.format("Berhasil memperbarui properti untuk %d instance.", updatedCount)
229
+ }
230
+ }
231
+ end
232
+
233
+ ]]></ProtectedString>
234
+ </Properties>
235
+ </Item>
13
236
  <Item class="ModuleScript" referent="RBX_create_instance">
14
237
  <Properties>
15
238
  <BinaryString name="AttributesSerialize"></BinaryString>
@@ -407,9 +630,32 @@ end
407
630
  tryProp(prop)
408
631
  end
409
632
 
410
- -- Attributes & Tags
633
+ -- Attributes & Tags (sanitasi tipe non-JSON agar tidak throw error di JSONEncode)
411
634
  pcall(function()
412
- props.Attributes = targetInstance:GetAttributes()
635
+ local rawAttrs = targetInstance:GetAttributes()
636
+ local safeAttrs = {}
637
+ for k, val in pairs(rawAttrs) do
638
+ if typeof(val) == "Vector3" then
639
+ safeAttrs[k] = {math.round(val.X*100)/100, math.round(val.Y*100)/100, math.round(val.Z*100)/100}
640
+ elseif typeof(val) == "Color3" then
641
+ safeAttrs[k] = {math.round(val.R * 255), math.round(val.G * 255), math.round(val.B * 255)}
642
+ elseif typeof(val) == "CFrame" then
643
+ safeAttrs[k] = tostring(val)
644
+ elseif typeof(val) == "EnumItem" then
645
+ safeAttrs[k] = val.Name
646
+ elseif typeof(val) == "UDim2" then
647
+ safeAttrs[k] = {val.X.Scale, val.X.Offset, val.Y.Scale, val.Y.Offset}
648
+ elseif typeof(val) == "Instance" then
649
+ safeAttrs[k] = val:GetFullName()
650
+ elseif typeof(val) == "DateTime" then
651
+ safeAttrs[k] = val:ToIsoDate()
652
+ elseif typeof(val) == "string" or typeof(val) == "number" or typeof(val) == "boolean" then
653
+ safeAttrs[k] = val
654
+ else
655
+ safeAttrs[k] = tostring(val)
656
+ end
657
+ end
658
+ props.Attributes = safeAttrs
413
659
  end)
414
660
  pcall(function()
415
661
  props.Tags = CollectionService:GetTags(targetInstance)
@@ -450,28 +696,101 @@ end
450
696
  <string name="Name">insert_asset</string>
451
697
  <int64 name="SourceAssetId">-1</int64>
452
698
  <BinaryString name="Tags"></BinaryString>
453
- <ProtectedString name="Source"><![CDATA[return function(targetInstance, data, ctx, targetPath)
454
- local InsertService = game:GetService("InsertService")
455
- local rawId = tostring(data or ""):gsub("rbxassetid://", ""):gsub("%s+", "")
699
+ <ProtectedString name="Source"><![CDATA[--!strict
700
+ -- ============================================================================
701
+ -- Skill Module: insert_asset (Upgraded with Asset Security Scanner & Script Stripper)
702
+ -- ============================================================================
703
+
704
+ local HttpService = game:GetService("HttpService")
705
+ local InsertService = game:GetService("InsertService")
706
+
707
+ local SecurityScanner = require(script.Parent:WaitForChild("security_scanner"))
708
+
709
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
710
+ local assetIdStr = ""
711
+ local stripScripts = true -- Default TRUE demi keamanan mutlak
712
+ local scanSecurity = true
713
+
714
+ if typeof(data) == "string" then
715
+ local success, parsed = pcall(function()
716
+ return HttpService:JSONDecode(data)
717
+ end)
718
+ if success and typeof(parsed) == "table" then
719
+ assetIdStr = tostring(parsed.asset_id or parsed.assetId or "")
720
+ if parsed.strip_scripts ~= nil then stripScripts = parsed.strip_scripts end
721
+ if parsed.scan_security ~= nil then scanSecurity = parsed.scan_security end
722
+ else
723
+ assetIdStr = data
724
+ end
725
+ elseif typeof(data) == "table" then
726
+ assetIdStr = tostring(data.asset_id or data.assetId or "")
727
+ if data.strip_scripts ~= nil then stripScripts = data.strip_scripts end
728
+ if data.scan_security ~= nil then scanSecurity = data.scan_security end
729
+ end
730
+
731
+ local rawId = string.gsub(assetIdStr, "rbxassetid://", "")
732
+ rawId = string.gsub(rawId, "%s+", "")
456
733
  local assetId = tonumber(rawId)
457
- if not assetId then
734
+
735
+ if not assetId or assetId <= 0 then
458
736
  return { status = "error", error = "Asset ID harus berupa angka yang valid." }
459
737
  end
460
-
738
+
461
739
  local success, model = pcall(function()
462
740
  return InsertService:LoadAsset(assetId)
463
741
  end)
464
-
465
- if success and model then
466
- if targetInstance == game or targetInstance.ClassName == "DataModel" then
467
- model.Parent = game.Workspace
742
+
743
+ if not success or not model then
744
+ return {
745
+ status = "error",
746
+ error = "Gagal memuat aset. Pastikan Asset ID benar dan model berstatus publik di Creator Store."
747
+ }
748
+ end
749
+
750
+ local securityNotes = {}
751
+ local removedScripts = {}
752
+
753
+ -- 1. Scan keamanan jika diaktifkan
754
+ if scanSecurity then
755
+ local scanResult = SecurityScanner.scanInstance(model, true)
756
+ if scanResult.riskLevel == "DANGEROUS" then
757
+ table.insert(securityNotes, string.format("⚠️ PERINGATAN: Aset ini memiliki %d ancaman BERBAHAYA (Backdoor/Obfuscation/Webhook)!", scanResult.highThreats))
758
+ end
759
+ end
760
+
761
+ -- 2. Sanitasi: Hapus skrip jika strip_scripts = true
762
+ if stripScripts then
763
+ removedScripts = SecurityScanner.stripScripts(model)
764
+ if #removedScripts > 0 then
765
+ table.insert(securityNotes, string.format("🛡️ Keamanan: %d skrip bawaan telah dihapus secara otomatis demi keamanan game Anda.", #removedScripts))
468
766
  else
469
- model.Parent = targetInstance
767
+ table.insert(securityNotes, "🛡️ Keamanan: Aset bersih, tidak ditemukan skrip berbahaya.")
470
768
  end
471
- return { status = "success", result = "Aset berhasil dimasukkan ke " .. model.Parent.Name .. "." }
472
769
  else
473
- return { status = "error", error = "Gagal memuat aset. Pastikan Asset ID benar dan akun/plugin Anda memiliki izin (Ownership/Public)." }
770
+ table.insert(securityNotes, "ℹ️ Catatan: strip_scripts = false, skrip bawaan model dipertahankan.")
474
771
  end
772
+
773
+ -- 3. Masukkan model ke target
774
+ local parentTarget = targetInstance
775
+ if not parentTarget or parentTarget == game or parentTarget.ClassName == "DataModel" then
776
+ parentTarget = game.Workspace
777
+ end
778
+
779
+ model.Parent = parentTarget
780
+
781
+ return {
782
+ status = "success",
783
+ result = {
784
+ message = string.format("Aset '%s' (ID: %d) berhasil dimasukkan ke %s.", model.Name, assetId, parentTarget.Name),
785
+ assetName = model.Name,
786
+ assetId = assetId,
787
+ parent = parentTarget:GetFullName(),
788
+ stripScriptsApplied = stripScripts,
789
+ removedScriptsCount = #removedScripts,
790
+ removedScripts = removedScripts,
791
+ securityNotes = table.concat(securityNotes, "\n"),
792
+ }
793
+ }
475
794
  end
476
795
  ]]></ProtectedString>
477
796
  </Properties>
@@ -488,10 +807,10 @@ end
488
807
  <BinaryString name="Tags"></BinaryString>
489
808
  <token name="RunContext">0</token>
490
809
  <ProtectedString name="Source"><![CDATA[-- ══════════════════════════════════════════════════════════════════════
491
- -- NVStudio MCP Plugin v2.1.8
492
- -- Arsitektur: Polling Stream / Local HTTP Bridge (Port 3055)
493
- -- Plugin auto-connect saat dimuat, polling /api/ping lalu /api/stream
494
- -- untuk mendapatkan events (status, request) dari server Node.js.
810
+ -- NVStudio MCP Plugin v2.3.0
811
+ -- Arsitektur: Dual-Mode WebSocket Duplex (Port 3055/ws) + Fallback Long-Polling
812
+ -- Plugin auto-connect saat dimuat via WebSocket native, dengan fallback otomatis
813
+ -- ke HTTP stream jika WebSocket tidak tersedia di environment Roblox Studio ini.
495
814
  -- ══════════════════════════════════════════════════════════════════════
496
815
 
497
816
  local HttpService = game:GetService("HttpService")
@@ -502,6 +821,8 @@ local BASE_URL = "http://localhost:3055"
502
821
  local STREAM_URL = BASE_URL .. "/api/stream" -- SSE long-poll (blocking GET)
503
822
  local RESPONSE_URL = BASE_URL .. "/api/response"
504
823
  local PING_URL = BASE_URL .. "/api/ping"
824
+ local WS_URL = "ws://localhost:3055/ws"
825
+ local WS_URL_IP = "ws://127.0.0.1:3055/ws"
505
826
 
506
827
  -- ID unik per sesi Studio
507
828
  local STUDIO_ID = HttpService:GenerateGUID(false)
@@ -523,18 +844,55 @@ local ctx = {
523
844
  getInstanceFromPath = nil -- akan di-bind di bawah
524
845
  }
525
846
  local MAX_LOGS = 50
526
- local isRunning = false -- apakah loop aktif
847
+ local isRunning = false -- apakah loop/koneksi aktif
527
848
  local isManualDisconnect = false
528
849
  local isClickDebounce = false
529
850
  local mcpAgentActive = false -- apakah Antigravity sedang konek ke MCP
530
851
  local serverReachable = false -- apakah server Node.js reachable
852
+ local activeWsWrapper = nil
853
+ local currentTransport = "none" -- "websocket" | "longpoll" | "none"
531
854
 
532
- -- Reconnect config
855
+ -- Reconnect config untuk Long-Polling
533
856
  local RECONNECT_DELAY_INITIAL = 1.5
534
857
  local RECONNECT_DELAY_MAX = 1.5
535
858
  local reconnectDelay = RECONNECT_DELAY_INITIAL
536
859
  local currentLoopId = nil
537
860
 
861
+ -- Task deduplication state (Bug G)
862
+ local runningTasks = {} -- taskId -> true
863
+ local completedTasks = {} -- taskId -> os.clock()
864
+
865
+ local function shouldProcessTask(taskId)
866
+ if not taskId or taskId == "" then return false end
867
+ if runningTasks[taskId] then
868
+ return false
869
+ end
870
+ local completedAt = completedTasks[taskId]
871
+ if completedAt and (os.clock() - completedAt < 60) then
872
+ return false
873
+ end
874
+ runningTasks[taskId] = true
875
+ return true
876
+ end
877
+
878
+ local function markTaskDone(taskId)
879
+ if not taskId then return end
880
+ runningTasks[taskId] = nil
881
+ completedTasks[taskId] = os.clock()
882
+
883
+ -- Bersihkan entri kedaluwarsa (>60 detik)
884
+ local now = os.clock()
885
+ for id, t in pairs(completedTasks) do
886
+ if now - t > 60 then
887
+ completedTasks[id] = nil
888
+ end
889
+ end
890
+ end
891
+
892
+ -- Forward declaration
893
+ local startConnectionManager = nil
894
+ local startLongPollingLoop = nil
895
+
538
896
  -- ── Skills (ModuleScript) ─────────────────────────────────────────────────────
539
897
  local skills = {}
540
898
  for _, module in ipairs(script.Parent:GetChildren()) do
@@ -577,12 +935,17 @@ if uiModule then
577
935
  reconnectDelay = RECONNECT_DELAY_INITIAL
578
936
  ui:setStatus("waiting")
579
937
  ui:addLog("[System] Menghubungkan ulang ke Server MCP...", Color3.fromRGB(255, 220, 100))
580
- task.spawn(startConnectionLoop)
938
+ task.spawn(startConnectionManager)
581
939
  else
582
940
  isManualDisconnect = true
583
941
  isRunning = false
584
942
  serverReachable = false
585
943
  mcpAgentActive = false
944
+ if activeWsWrapper then
945
+ activeWsWrapper:Close()
946
+ activeWsWrapper = nil
947
+ end
948
+ currentTransport = "none"
586
949
  ui:setStatus("disconnected")
587
950
  ui:addLog("[System] Koneksi dihentikan oleh pengguna.", Color3.fromRGB(255, 100, 100))
588
951
  end
@@ -689,31 +1052,325 @@ local function processTask(taskData)
689
1052
  return result
690
1053
  end
691
1054
 
692
- -- ── Kirim Respons ke Server ───────────────────────────────────────────────────
693
- local function sendResponse(taskId, resultData)
1055
+ -- ── Helper: Sanitize result sebelum JSONEncode (Bug H) ─────────────────────────
1056
+ local function sanitizeForJson(val, depth)
1057
+ depth = depth or 0
1058
+ if depth > 5 then return tostring(val) end
1059
+ local t = typeof(val)
1060
+ if t == "string" or t == "number" or t == "boolean" then
1061
+ return val
1062
+ elseif t == "Instance" then
1063
+ return val:GetFullName()
1064
+ elseif t == "Vector3" then
1065
+ return { X = val.X, Y = val.Y, Z = val.Z }
1066
+ elseif t == "CFrame" then
1067
+ return { Position = { X = val.Position.X, Y = val.Position.Y, Z = val.Position.Z } }
1068
+ elseif t == "Color3" then
1069
+ return { R = val.R, G = val.G, B = val.B }
1070
+ elseif t == "table" then
1071
+ local sanitized = {}
1072
+ for k, v in pairs(val) do
1073
+ sanitized[tostring(k)] = sanitizeForJson(v, depth + 1)
1074
+ end
1075
+ return sanitized
1076
+ else
1077
+ return tostring(val)
1078
+ end
1079
+ end
1080
+
1081
+ -- ── Kirim Respons ke Server (HTTP Fallback) (Bug H) ───────────────────────────
1082
+ local function sendResponse(taskId, resultData, attempt)
694
1083
  task.spawn(function()
695
- local ok, err = pcall(function()
696
- local json = HttpService:JSONEncode(resultData)
697
- local success = pcall(function()
698
- HttpService:PostAsync(
699
- RESPONSE_URL .. "/" .. taskId,
700
- json,
701
- Enum.HttpContentType.ApplicationJson
702
- )
703
- end)
704
- if not success then
1084
+ local payload = {
1085
+ id = taskId,
1086
+ taskId = taskId,
1087
+ attempt = attempt,
1088
+ status = resultData.status or "success",
1089
+ result = sanitizeForJson(resultData.result),
1090
+ error = resultData.error and tostring(resultData.error) or nil,
1091
+ }
1092
+
1093
+ local encOk, json = pcall(function()
1094
+ return HttpService:JSONEncode(payload)
1095
+ end)
1096
+
1097
+ if not encOk then
1098
+ payload.result = tostring(payload.result)
1099
+ pcall(function() json = HttpService:JSONEncode(payload) end)
1100
+ end
1101
+
1102
+ if not json then
1103
+ json = string.format('{"id":"%s","taskId":"%s","status":"error","error":"Gagal encode payload JSON"}', taskId, taskId)
1104
+ end
1105
+
1106
+ local postOk = pcall(function()
1107
+ HttpService:PostAsync(
1108
+ RESPONSE_URL .. "/" .. taskId,
1109
+ json,
1110
+ Enum.HttpContentType.ApplicationJson
1111
+ )
1112
+ end)
1113
+
1114
+ if not postOk then
1115
+ -- Backoff sejenak sebelum fallback ke endpoint legacy (Bug H)
1116
+ task.wait(0.2)
1117
+ local fallbackOk = pcall(function()
705
1118
  HttpService:PostAsync(RESPONSE_URL, json, Enum.HttpContentType.ApplicationJson)
1119
+ end)
1120
+ if not fallbackOk and ui then
1121
+ ui:addLog("[System] Gagal kirim response HTTP ke server: " .. tostring(taskId), Color3.fromRGB(255, 100, 100))
706
1122
  end
707
- end)
708
- if not ok and ui then
709
- ui:addLog("[System] Gagal kirim response: " .. tostring(err), Color3.fromRGB(255, 100, 100))
710
1123
  end
711
1124
  end)
712
1125
  end
713
1126
 
714
- -- ── Proses satu event dari SSE stream ────────────────────────────────────────
1127
+ -- ── WebSocket API Abstraction ────────────────────────────────────────────────
1128
+ local function detectWebSocketCapability()
1129
+ -- 1. Check HttpService:CreateWebStreamClient (Roblox Studio native API)
1130
+ local hasWebStream = false
1131
+ pcall(function()
1132
+ hasWebStream = (typeof(HttpService.CreateWebStreamClient) == "function")
1133
+ and (Enum.WebStreamClientType and Enum.WebStreamClientType.WebSocket ~= nil)
1134
+ end)
1135
+ if hasWebStream then
1136
+ return "WebStreamClient"
1137
+ end
1138
+
1139
+ -- 2. Check global WebSocket API jika tersedia
1140
+ local wsGlobal = rawget(_G, "WebSocket") or rawget(_G, "websocket")
1141
+ if not wsGlobal then
1142
+ pcall(function() wsGlobal = WebSocket end)
1143
+ end
1144
+ if wsGlobal and typeof(wsGlobal.connect) == "function" then
1145
+ return "WebSocketGlobal"
1146
+ end
1147
+
1148
+ return nil
1149
+ end
1150
+
1151
+ local function createWsClient(url)
1152
+ local cap = detectWebSocketCapability()
1153
+ if not cap then
1154
+ return nil, "WebSocket API not available"
1155
+ end
1156
+
1157
+ if cap == "WebStreamClient" then
1158
+ local ok, client = pcall(function()
1159
+ return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, {
1160
+ Url = url
1161
+ })
1162
+ end)
1163
+ if not ok or not client then
1164
+ return nil, tostring(client or "CreateWebStreamClient failed")
1165
+ end
1166
+
1167
+ local wrapper = {
1168
+ raw = client,
1169
+ cap = "WebStreamClient",
1170
+ closed = false,
1171
+ connections = {},
1172
+ }
1173
+
1174
+ function wrapper:Send(data)
1175
+ if self.closed then return false, "Closed" end
1176
+ return pcall(function()
1177
+ self.raw:Send(data)
1178
+ end)
1179
+ end
1180
+
1181
+ function wrapper:Close()
1182
+ if self.closed then return end
1183
+ self.closed = true
1184
+ pcall(function() self.raw:Close() end)
1185
+ for _, conn in ipairs(self.connections) do
1186
+ pcall(function() conn:Disconnect() end)
1187
+ end
1188
+ table.clear(self.connections)
1189
+ end
1190
+
1191
+ function wrapper:BindEvents(onMessage, onClose)
1192
+ if self.raw.MessageReceived and typeof(self.raw.MessageReceived.Connect) == "function" then
1193
+ local ok, conn = pcall(function()
1194
+ return self.raw.MessageReceived:Connect(onMessage)
1195
+ end)
1196
+ if ok and conn then table.insert(self.connections, conn) end
1197
+ end
1198
+
1199
+ if self.raw.Closed and typeof(self.raw.Closed.Connect) == "function" then
1200
+ local ok, conn = pcall(function()
1201
+ return self.raw.Closed:Connect(onClose)
1202
+ end)
1203
+ if ok and conn then table.insert(self.connections, conn) end
1204
+ end
1205
+ end
1206
+
1207
+ return wrapper, nil
1208
+
1209
+ elseif cap == "WebSocketGlobal" then
1210
+ local wsGlobal = rawget(_G, "WebSocket") or rawget(_G, "websocket") or WebSocket
1211
+ local ok, client = pcall(function()
1212
+ return wsGlobal.connect(url)
1213
+ end)
1214
+ if not ok or not client then
1215
+ return nil, tostring(client or "WebSocket.connect failed")
1216
+ end
1217
+
1218
+ local wrapper = {
1219
+ raw = client,
1220
+ cap = "WebSocketGlobal",
1221
+ closed = false,
1222
+ connections = {},
1223
+ }
1224
+
1225
+ function wrapper:Send(data)
1226
+ if self.closed then return false, "Closed" end
1227
+ return pcall(function()
1228
+ if typeof(self.raw.Send) == "function" then
1229
+ self.raw:Send(data)
1230
+ elseif typeof(self.raw.send) == "function" then
1231
+ self.raw:send(data)
1232
+ end
1233
+ end)
1234
+ end
1235
+
1236
+ function wrapper:Close()
1237
+ if self.closed then return end
1238
+ self.closed = true
1239
+ pcall(function()
1240
+ if typeof(self.raw.Close) == "function" then
1241
+ self.raw:Close()
1242
+ elseif typeof(self.raw.close) == "function" then
1243
+ self.raw:close()
1244
+ end
1245
+ end)
1246
+ for _, conn in ipairs(self.connections) do
1247
+ pcall(function() conn:Disconnect() end)
1248
+ end
1249
+ table.clear(self.connections)
1250
+ end
1251
+
1252
+ function wrapper:BindEvents(onMessage, onClose)
1253
+ if self.raw.MessageReceived and typeof(self.raw.MessageReceived.Connect) == "function" then
1254
+ local ok, conn = pcall(function() return self.raw.MessageReceived:Connect(onMessage) end)
1255
+ if ok and conn then table.insert(self.connections, conn) end
1256
+ elseif self.raw.OnMessage then
1257
+ if typeof(self.raw.OnMessage) == "RBXScriptSignal" or (typeof(self.raw.OnMessage) == "userdata" and self.raw.OnMessage.Connect) then
1258
+ local ok, conn = pcall(function() return self.raw.OnMessage:Connect(onMessage) end)
1259
+ if ok and conn then table.insert(self.connections, conn) end
1260
+ else
1261
+ pcall(function() self.raw.OnMessage = onMessage end)
1262
+ end
1263
+ end
1264
+
1265
+ if self.raw.Closed and typeof(self.raw.Closed.Connect) == "function" then
1266
+ local ok, conn = pcall(function() return self.raw.Closed:Connect(onClose) end)
1267
+ if ok and conn then table.insert(self.connections, conn) end
1268
+ elseif self.raw.OnClose then
1269
+ if typeof(self.raw.OnClose) == "RBXScriptSignal" or (typeof(self.raw.OnClose) == "userdata" and self.raw.OnClose.Connect) then
1270
+ local ok, conn = pcall(function() return self.raw.OnClose:Connect(onClose) end)
1271
+ if ok and conn then table.insert(self.connections, conn) end
1272
+ else
1273
+ pcall(function() self.raw.OnClose = onClose end)
1274
+ end
1275
+ end
1276
+ end
1277
+
1278
+ return wrapper, nil
1279
+ end
1280
+
1281
+ return nil, "Unsupported"
1282
+ end
1283
+
1284
+ -- ── WebSocket Message Dispatcher ─────────────────────────────────────────────
1285
+ local function handleWsMessage(wrapper, rawMessage)
1286
+ if type(rawMessage) ~= "string" or rawMessage == "" then return end
1287
+ local ok, decoded = pcall(function() return HttpService:JSONDecode(rawMessage) end)
1288
+ if not ok or type(decoded) ~= "table" then return end
1289
+
1290
+ local kind = decoded.kind
1291
+
1292
+ -- Heartbeat ping -> balas pong
1293
+ if kind == "ping" then
1294
+ pcall(function()
1295
+ wrapper:Send(HttpService:JSONEncode({ kind = "pong", ts = os.time() }))
1296
+ end)
1297
+ return
1298
+ end
1299
+
1300
+ if kind == "pong" then
1301
+ return
1302
+ end
1303
+
1304
+ -- Update status koneksi MCP / Studio
1305
+ if kind == "status" then
1306
+ local newActive = decoded.mcpConnected == true
1307
+ local changed = (mcpAgentActive ~= newActive)
1308
+ mcpAgentActive = newActive
1309
+ if ui then
1310
+ if mcpAgentActive then
1311
+ ui:setStatus("connected")
1312
+ else
1313
+ ui:setStatus("server_connected")
1314
+ end
1315
+ if newActive and changed then
1316
+ ui:addLog("[System] ✅ IDE Agent (Antigravity) aktif ⚡", Color3.fromRGB(100, 255, 100))
1317
+ elseif not newActive and changed then
1318
+ ui:addLog("[System] ⚠️ IDE Agent terputus.", Color3.fromRGB(255, 150, 50))
1319
+ end
1320
+ end
1321
+ return
1322
+ end
1323
+
1324
+ -- Eksekusi perintah AI
1325
+ if kind == "request" then
1326
+ local taskId = decoded.taskId or decoded.requestId
1327
+ local attempt = decoded.attempt
1328
+ local command = decoded.command
1329
+ local target = decoded.target
1330
+ local data = decoded.data
1331
+
1332
+ if not taskId or not command then return end
1333
+ if not shouldProcessTask(taskId) then return end -- Bug G: Dedup!
1334
+
1335
+ task.spawn(function()
1336
+ local resultData = processTask({
1337
+ id = taskId,
1338
+ command = command,
1339
+ target = target or "",
1340
+ data = data,
1341
+ })
1342
+ markTaskDone(taskId) -- Bug G: Tandai selesai
1343
+ if resultData then
1344
+ -- Push respons instan kembali via WebSocket (Zero-Delay)
1345
+ local responseEnvelope = {
1346
+ kind = "response",
1347
+ taskId = taskId,
1348
+ id = taskId,
1349
+ attempt = attempt,
1350
+ status = resultData.status,
1351
+ result = sanitizeForJson(resultData.result),
1352
+ error = resultData.error and tostring(resultData.error) or nil,
1353
+ }
1354
+ local encOk, encoded = pcall(function()
1355
+ return HttpService:JSONEncode(responseEnvelope)
1356
+ end)
1357
+ local sent = false
1358
+ if encOk then
1359
+ local sendOk, _ = wrapper:Send(encoded)
1360
+ sent = (sendOk == true)
1361
+ end
1362
+ if not sent then
1363
+ -- Jika serialisasi atau pengiriman via WS gagal, fallback ke HTTP POST
1364
+ sendResponse(taskId, resultData, attempt)
1365
+ end
1366
+ end
1367
+ end)
1368
+ return
1369
+ end
1370
+ end
1371
+
1372
+ -- ── Proses satu event dari SSE stream (Long-Polling fallback) ────────────────
715
1373
  local function handleSSELine(line)
716
- -- Format SSE: "data: {...json...}"
717
1374
  local payload = line
718
1375
  if line:sub(1, 6) == "event:" then return end
719
1376
  if line:sub(1, 5) == "data:" then
@@ -726,12 +1383,10 @@ local function handleSSELine(line)
726
1383
 
727
1384
  local kind = decoded.kind
728
1385
 
729
- -- heartbeat: server masih hidup
730
1386
  if kind == "heartbeat" then
731
1387
  return
732
1388
  end
733
1389
 
734
- -- status: update mcpConnected
735
1390
  if kind == "status" then
736
1391
  local newActive = decoded.mcpConnected == true
737
1392
  local changed = (mcpAgentActive ~= newActive)
@@ -752,14 +1407,15 @@ local function handleSSELine(line)
752
1407
  return
753
1408
  end
754
1409
 
755
- -- request: ada task dari AI agent
756
1410
  if kind == "request" then
757
- local taskId = decoded.requestId
1411
+ local taskId = decoded.requestId or decoded.taskId
1412
+ local attempt = decoded.attempt
758
1413
  local command = decoded.command
759
1414
  local target = decoded.target
760
1415
  local data = decoded.data
761
1416
 
762
1417
  if not taskId or not command then return end
1418
+ if not shouldProcessTask(taskId) then return end -- Bug G: Dedup!
763
1419
 
764
1420
  task.spawn(function()
765
1421
  local resultData = processTask({
@@ -768,39 +1424,34 @@ local function handleSSELine(line)
768
1424
  target = target or "",
769
1425
  data = data,
770
1426
  })
1427
+ markTaskDone(taskId) -- Bug G: Tandai selesai
771
1428
  if resultData then
772
- sendResponse(taskId, resultData)
1429
+ sendResponse(taskId, resultData, attempt)
773
1430
  end
774
1431
  end)
775
1432
  return
776
1433
  end
777
1434
  end
778
1435
 
779
- -- ── Loop Utama: Ping → Stream → Parse SSE lines ───────────────────────────────
780
- -- Roblox HttpService:GetAsync() tidak mendukung streaming, jadi kita
781
- -- menggunakan strategi: GET /api/stream dengan waktu hold singkat (5 detik).
782
- -- Server mengirim semua events yang tertunda lalu tutup koneksi.
783
- -- Plugin reconnect langsung untuk sesi berikutnya.
784
- function startConnectionLoop()
785
- if isRunning then return end
786
- isRunning = true
787
- isManualDisconnect = false
1436
+ -- ── Fallback Loop: Long-Polling (/api/stream) ─────────────────────────────────
1437
+ function startLongPollingLoop()
1438
+ if not isRunning or isManualDisconnect then return end
1439
+ currentTransport = "longpoll"
788
1440
  reconnectDelay = RECONNECT_DELAY_INITIAL
789
1441
 
790
1442
  local loopId = HttpService:GenerateGUID(false)
791
1443
  currentLoopId = loopId
792
1444
 
793
1445
  if ui then
794
- ui:addLog("[System] NVStudio MCP v2.1.8 — Menghubungkan ke server...", Color3.fromRGB(200, 200, 200))
1446
+ ui:addLog("[System] Mode Long-Polling aktif (Port 3055)...", Color3.fromRGB(200, 200, 200))
795
1447
  end
796
1448
 
797
1449
  local consecutiveErrors = 0
798
1450
 
799
- while isRunning and not isManualDisconnect and currentLoopId == loopId do
800
- -- Stream / polling snapshot dari server
1451
+ while isRunning and not isManualDisconnect and currentLoopId == loopId and currentTransport == "longpoll" do
801
1452
  local streamOk, streamRaw = pcall(function()
802
1453
  return HttpService:GetAsync(
803
- STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID),
1454
+ STREAM_URL .. "?studioId=" .. HttpService:UrlEncode(STUDIO_ID) .. "&placeId=" .. tostring(game.PlaceId),
804
1455
  false
805
1456
  )
806
1457
  end)
@@ -813,28 +1464,26 @@ function startConnectionLoop()
813
1464
  serverReachable = true
814
1465
  if ui then
815
1466
  ui:setStatus(mcpAgentActive and "connected" or "server_connected")
816
- ui:addLog("[System] ✅ Server MCP terhubung & siap.", Color3.fromRGB(100, 255, 150))
1467
+ ui:addLog("[System] ✅ Server MCP terhubung via Long-Polling.", Color3.fromRGB(100, 255, 150))
817
1468
  end
818
1469
  end
819
1470
 
820
- -- Parse baris-baris SSE
821
1471
  for line in (streamRaw .. "\n"):gmatch("([^\n]*)\n") do
822
1472
  line = line:gsub("\r", "")
823
1473
  if line ~= "" then
824
1474
  handleSSELine(line)
825
1475
  end
826
1476
  end
827
- task.wait(0.05) -- Instant loop: server menahan request (hanging GET) saat idle
1477
+ task.wait()
828
1478
  else
829
1479
  consecutiveErrors = consecutiveErrors + 1
830
- -- Toleransi 2x gagal sebelum menandai server unreachable
831
1480
  if consecutiveErrors >= 2 then
832
1481
  if serverReachable then
833
1482
  serverReachable = false
834
1483
  mcpAgentActive = false
835
1484
  if ui then
836
1485
  ui:setStatus("waiting")
837
- ui:addLog("[System] ⚠️ Server MCP tidak dapat dijangkau. Mencoba menghubungkan ulang...", Color3.fromRGB(255, 150, 50))
1486
+ ui:addLog("[System] ⚠️ Server MCP tidak dapat dijangkau. Menghubungkan ulang...", Color3.fromRGB(255, 150, 50))
838
1487
  end
839
1488
  end
840
1489
  end
@@ -844,24 +1493,125 @@ function startConnectionLoop()
844
1493
  end
845
1494
  end
846
1495
 
847
- isRunning = false
848
- serverReachable = false
849
- mcpAgentActive = false
850
- if ui then ui:setStatus("disconnected") end
1496
+ if currentTransport == "longpoll" and (not isRunning or isManualDisconnect) then
1497
+ isRunning = false
1498
+ serverReachable = false
1499
+ mcpAgentActive = false
1500
+ if ui then ui:setStatus("disconnected") end
1501
+ end
1502
+ end
1503
+
1504
+ -- ── Connection Manager: WebSocket Utama + Fallback Long-Polling ──────────────
1505
+ function startConnectionManager()
1506
+ if isRunning then return end
1507
+ isRunning = true
1508
+ isManualDisconnect = false
1509
+
1510
+ local cap = detectWebSocketCapability()
1511
+ if cap then
1512
+ task.spawn(function()
1513
+ local wsFailures = 0
1514
+ local backoff = 0.5
1515
+
1516
+ while isRunning and not isManualDisconnect and wsFailures < 3 do
1517
+ currentTransport = "websocket"
1518
+ if ui then
1519
+ ui:setStatus("waiting")
1520
+ ui:addLog("[System] Menghubungkan via WebSocket (Port 3055)...", Color3.fromRGB(200, 200, 255))
1521
+ end
1522
+
1523
+ local wrapper, err = createWsClient(WS_URL)
1524
+ if not wrapper then
1525
+ -- Coba fallback ke IP 127.0.0.1
1526
+ wrapper, err = createWsClient(WS_URL_IP)
1527
+ end
1528
+
1529
+ if wrapper then
1530
+ activeWsWrapper = wrapper
1531
+ serverReachable = true
1532
+ if ui then
1533
+ ui:setStatus(mcpAgentActive and "connected" or "server_connected")
1534
+ ui:addLog("[System] ⚡ WebSocket Terhubung! (Ultra Low-Latency <1ms)", Color3.fromRGB(100, 255, 150))
1535
+ end
1536
+
1537
+ -- Kirim pesan registrasi awal
1538
+ local regOk = wrapper:Send(HttpService:JSONEncode({
1539
+ kind = "register",
1540
+ studioId = STUDIO_ID,
1541
+ placeId = game.PlaceId,
1542
+ pluginsVersion = 2,
1543
+ }))
1544
+
1545
+ if regOk then
1546
+ wsFailures = 0
1547
+ backoff = 0.5
1548
+
1549
+ local closedSignal = Instance.new("BindableEvent")
1550
+ wrapper:BindEvents(
1551
+ function(msg)
1552
+ handleWsMessage(wrapper, msg)
1553
+ end,
1554
+ function()
1555
+ closedSignal:Fire()
1556
+ end
1557
+ )
1558
+
1559
+ closedSignal.Event:Wait()
1560
+ closedSignal:Destroy()
1561
+ end
1562
+
1563
+ if activeWsWrapper == wrapper then
1564
+ activeWsWrapper = nil
1565
+ end
1566
+ wrapper:Close()
1567
+ end
1568
+
1569
+ if isManualDisconnect or not isRunning then break end
1570
+
1571
+ wsFailures = wsFailures + 1
1572
+ if wsFailures < 3 then
1573
+ if ui then
1574
+ ui:setStatus("waiting")
1575
+ ui:addLog("[System] ⚠️ WebSocket terputus. Reconnect dalam " .. string.format("%.1f", backoff) .. "s...", Color3.fromRGB(255, 180, 50))
1576
+ end
1577
+ task.wait(backoff)
1578
+ backoff = math.min(backoff * 2, 5.0)
1579
+ end
1580
+ end
1581
+
1582
+ -- Jika WS gagal 3x berturut-turut dan plugin masih aktif -> Beralih ke Long-Polling!
1583
+ if isRunning and not isManualDisconnect then
1584
+ if ui then
1585
+ ui:addLog("[System] ℹ️ Mengalihkan ke Mode Fallback Long-Polling...", Color3.fromRGB(255, 200, 100))
1586
+ end
1587
+ startLongPollingLoop()
1588
+ end
1589
+ end)
1590
+ else
1591
+ -- WebSocket tidak didukung di environment Studio ini -> langsung Long-Polling
1592
+ if ui then
1593
+ ui:addLog("[System] ℹ️ WebSocket tidak terdeteksi. Menggunakan Long-Polling.", Color3.fromRGB(200, 200, 200))
1594
+ end
1595
+ startLongPollingLoop()
1596
+ end
851
1597
  end
852
1598
 
853
1599
  -- ── Plugin Unloading ──────────────────────────────────────────────────────────
854
1600
  plugin.Unloading:Connect(function()
855
1601
  isManualDisconnect = true
856
1602
  isRunning = false
1603
+ if activeWsWrapper then
1604
+ activeWsWrapper:Close()
1605
+ activeWsWrapper = nil
1606
+ end
857
1607
  end)
858
1608
 
859
1609
  -- ══════════════════════════════════════════════════════════════════════
860
- -- STARTUP: Langsung mulai connection loop
1610
+ -- STARTUP: Langsung mulai connection manager
861
1611
  -- ══════════════════════════════════════════════════════════════════════
862
1612
  task.spawn(function()
863
1613
  task.wait(0.5) -- Tunggu UI siap
864
- startConnectionLoop()
1614
+ startConnectionManager()
865
1615
  end)
866
1616
  ]]></ProtectedString>
867
1617
  </Properties>
@@ -993,6 +1743,118 @@ return function(targetInstance, data, ctx, targetPath)
993
1743
  affected_lines = affectedLines
994
1744
  }
995
1745
  end
1746
+ ]]></ProtectedString>
1747
+ </Properties>
1748
+ </Item>
1749
+ <Item class="ModuleScript" referent="RBX_preview_asset">
1750
+ <Properties>
1751
+ <BinaryString name="AttributesSerialize"></BinaryString>
1752
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
1753
+ <bool name="DefinesCapabilities">false</bool>
1754
+ <bool name="Disabled">false</bool>
1755
+ <Content name="LinkedSource"><null></null></Content>
1756
+ <string name="Name">preview_asset</string>
1757
+ <int64 name="SourceAssetId">-1</int64>
1758
+ <BinaryString name="Tags"></BinaryString>
1759
+ <ProtectedString name="Source"><![CDATA[--!strict
1760
+ -- ============================================================================
1761
+ -- Skill Module: preview_asset
1762
+ -- Deskripsi: Pratinjau & Pemindaian Keamanan Aset Creator Store Secara Terisolasi
1763
+ -- ============================================================================
1764
+
1765
+ local HttpService = game:GetService("HttpService")
1766
+ local InsertService = game:GetService("InsertService")
1767
+
1768
+ local SecurityScanner = require(script.Parent:WaitForChild("security_scanner"))
1769
+
1770
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
1771
+ local assetIdStr = ""
1772
+ local deepScan = true
1773
+
1774
+ if typeof(data) == "string" then
1775
+ local success, parsed = pcall(function()
1776
+ return HttpService:JSONDecode(data)
1777
+ end)
1778
+ if success and typeof(parsed) == "table" then
1779
+ assetIdStr = tostring(parsed.asset_id or parsed.assetId or "")
1780
+ if parsed.deep_scan ~= nil then deepScan = parsed.deep_scan end
1781
+ else
1782
+ assetIdStr = data
1783
+ end
1784
+ elseif typeof(data) == "table" then
1785
+ assetIdStr = tostring(data.asset_id or data.assetId or "")
1786
+ if data.deep_scan ~= nil then deepScan = data.deep_scan end
1787
+ end
1788
+
1789
+ local cleanIdStr = string.gsub(assetIdStr, "rbxassetid://", "")
1790
+ cleanIdStr = string.gsub(cleanIdStr, "%s+", "")
1791
+ local assetId = tonumber(cleanIdStr)
1792
+
1793
+ if not assetId or assetId <= 0 then
1794
+ return { status = "error", error = "Asset ID tidak valid. Berikan Asset ID angka (contoh: 123456789)." }
1795
+ end
1796
+
1797
+ -- Muat Aset secara terisolasi (unparented / memory only)
1798
+ local ok, model = pcall(function()
1799
+ return InsertService:LoadAsset(assetId)
1800
+ end)
1801
+
1802
+ if not ok or not model then
1803
+ return {
1804
+ status = "error",
1805
+ error = string.format("Gagal memuat aset ID %d. Pastikan Asset ID publik dan terdaftar di Creator Store.", assetId)
1806
+ }
1807
+ end
1808
+
1809
+ local scanResult
1810
+ local scanOk, err = pcall(function()
1811
+ scanResult = SecurityScanner.scanInstance(model, deepScan)
1812
+ end)
1813
+
1814
+ local rootName = model.Name
1815
+ local childrenOverview = {}
1816
+ for _, child in ipairs(model:GetChildren()) do
1817
+ table.insert(childrenOverview, {
1818
+ name = child.Name,
1819
+ className = child.ClassName,
1820
+ })
1821
+ end
1822
+
1823
+ -- Hancurkan model sementara dari memori (Cleanup Mutlak)
1824
+ pcall(function()
1825
+ model:Destroy()
1826
+ end)
1827
+
1828
+ if not scanOk or not scanResult then
1829
+ return {
1830
+ status = "error",
1831
+ error = "Gagal memindai aset: " .. tostring(err)
1832
+ }
1833
+ end
1834
+
1835
+ local recommendation = "Aman untuk dimasukkan langsung menggunakan insert_asset."
1836
+ if scanResult.riskLevel == "DANGEROUS" then
1837
+ recommendation = "PERINGATAN: Aset ini memiliki ancaman BERBAHAYA! Wajib gunakan 'insert_asset' dengan strip_scripts = true, atau hindari aset ini."
1838
+ elseif scanResult.riskLevel == "WARNING" then
1839
+ recommendation = "Perhatian: Aset mengandung skrip atau peringatan moderat. Periksa kembali atau gunakan strip_scripts = true jika skrip tidak diperlukan."
1840
+ end
1841
+
1842
+ return {
1843
+ status = "success",
1844
+ result = {
1845
+ assetId = assetId,
1846
+ assetName = rootName,
1847
+ riskLevel = scanResult.riskLevel,
1848
+ recommendation = recommendation,
1849
+ stats = scanResult.stats,
1850
+ highThreatsCount = scanResult.highThreats,
1851
+ mediumThreatsCount = scanResult.mediumThreats,
1852
+ threats = scanResult.threats,
1853
+ topLevelChildren = childrenOverview,
1854
+ }
1855
+ }
1856
+ end
1857
+
996
1858
  ]]></ProtectedString>
997
1859
  </Properties>
998
1860
  </Item>
@@ -1025,6 +1887,100 @@ end
1025
1887
  return { status = "error", error = "Target BUKAN Script (ClassName: " .. targetInstance.ClassName .. ")." }
1026
1888
  end
1027
1889
  end
1890
+ ]]></ProtectedString>
1891
+ </Properties>
1892
+ </Item>
1893
+ <Item class="ModuleScript" referent="RBX_scan_security">
1894
+ <Properties>
1895
+ <BinaryString name="AttributesSerialize"></BinaryString>
1896
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
1897
+ <bool name="DefinesCapabilities">false</bool>
1898
+ <bool name="Disabled">false</bool>
1899
+ <Content name="LinkedSource"><null></null></Content>
1900
+ <string name="Name">scan_security</string>
1901
+ <int64 name="SourceAssetId">-1</int64>
1902
+ <BinaryString name="Tags"></BinaryString>
1903
+ <ProtectedString name="Source"><![CDATA[--!strict
1904
+ -- ============================================================================
1905
+ -- Skill Module: scan_security
1906
+ -- Deskripsi: Audit Keamanan Instance/Model yang Sudah Ada di Tempat Kerja Studio
1907
+ -- ============================================================================
1908
+
1909
+ local HttpService = game:GetService("HttpService")
1910
+ local SecurityScanner = require(script.Parent:WaitForChild("security_scanner"))
1911
+
1912
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
1913
+ local target = targetInstance or game.Workspace
1914
+ local deepScan = true
1915
+ local cleanThreats = false
1916
+
1917
+ if typeof(data) == "string" and #data > 0 then
1918
+ local success, parsed = pcall(function()
1919
+ return HttpService:JSONDecode(data)
1920
+ end)
1921
+ if success and typeof(parsed) == "table" then
1922
+ if parsed.deep_scan ~= nil then deepScan = parsed.deep_scan end
1923
+ if parsed.clean_threats ~= nil then cleanThreats = parsed.clean_threats end
1924
+ end
1925
+ elseif typeof(data) == "table" then
1926
+ if data.deep_scan ~= nil then deepScan = data.deep_scan end
1927
+ if data.clean_threats ~= nil then cleanThreats = data.clean_threats end
1928
+ end
1929
+
1930
+ local scanResult
1931
+ local ok, err = pcall(function()
1932
+ scanResult = SecurityScanner.scanInstance(target, deepScan)
1933
+ end)
1934
+
1935
+ if not ok or not scanResult then
1936
+ return {
1937
+ status = "error",
1938
+ error = "Gagal memindai target: " .. tostring(err)
1939
+ }
1940
+ end
1941
+
1942
+ local cleanedScripts = {}
1943
+ if cleanThreats and #scanResult.threats > 0 then
1944
+ -- Hapus skrip yang terdeteksi bahaya (HIGH severity)
1945
+ local scriptsToDestroy = {}
1946
+ for _, threat in ipairs(scanResult.threats) do
1947
+ if threat.severity == "HIGH" and threat.scriptPath then
1948
+ scriptsToDestroy[threat.scriptPath] = true
1949
+ end
1950
+ end
1951
+
1952
+ local function purge(instance: Instance)
1953
+ for _, desc in ipairs(instance:GetDescendants()) do
1954
+ if desc:IsA("LuaSourceContainer") and scriptsToDestroy[desc:GetFullName()] then
1955
+ table.insert(cleanedScripts, {
1956
+ name = desc.Name,
1957
+ className = desc.ClassName,
1958
+ path = desc:GetFullName(),
1959
+ })
1960
+ pcall(function() desc:Destroy() end)
1961
+ end
1962
+ end
1963
+ end
1964
+
1965
+ purge(target)
1966
+ end
1967
+
1968
+ return {
1969
+ status = "success",
1970
+ result = {
1971
+ target = target:GetFullName(),
1972
+ riskLevel = scanResult.riskLevel,
1973
+ stats = scanResult.stats,
1974
+ highThreatsCount = scanResult.highThreats,
1975
+ mediumThreatsCount = scanResult.mediumThreats,
1976
+ threats = scanResult.threats,
1977
+ cleanedThreatsCount = #cleanedScripts,
1978
+ cleanedScripts = cleanedScripts,
1979
+ isCleaned = cleanThreats,
1980
+ }
1981
+ }
1982
+ end
1983
+
1028
1984
  ]]></ProtectedString>
1029
1985
  </Properties>
1030
1986
  </Item>
@@ -1170,6 +2126,365 @@ end
1170
2126
  return { status = "success", result = "Tidak ditemukan instance dengan nama: " .. searchName }
1171
2127
  end
1172
2128
  end
2129
+ ]]></ProtectedString>
2130
+ </Properties>
2131
+ </Item>
2132
+ <Item class="ModuleScript" referent="RBX_security_scanner">
2133
+ <Properties>
2134
+ <BinaryString name="AttributesSerialize"></BinaryString>
2135
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
2136
+ <bool name="DefinesCapabilities">false</bool>
2137
+ <bool name="Disabled">false</bool>
2138
+ <Content name="LinkedSource"><null></null></Content>
2139
+ <string name="Name">security_scanner</string>
2140
+ <int64 name="SourceAssetId">-1</int64>
2141
+ <BinaryString name="Tags"></BinaryString>
2142
+ <ProtectedString name="Source"><![CDATA[--!strict
2143
+ -- ============================================================================
2144
+ -- Module: security_scanner
2145
+ -- Deskripsi: Engine Heuristik Pemindaian Keamanan & Backdoor Detection
2146
+ -- Standar: Business-Grade, Zero-Execution Isolation, Comprehensive Threat Rules
2147
+ -- ============================================================================
2148
+
2149
+ local SecurityScanner = {}
2150
+
2151
+ -- Daftar nama skrip yang sering digunakan oleh virus/backdoor klasik Roblox
2152
+ local SUSPICIOUS_SCRIPT_NAMES = {
2153
+ "[Vv]accine",
2154
+ "[Aa]nti[Ll]ag",
2155
+ "[Ff]ix[Ll]ag",
2156
+ "[Ii]nfection",
2157
+ "[Ff]ire",
2158
+ "[Cc]lean",
2159
+ "[Vv]irus",
2160
+ "4[Dd]",
2161
+ "4[Dd]_[Pp]lugin",
2162
+ "[Ss]pread",
2163
+ "[Ll]ag[Ff]ixer",
2164
+ "[Dd]up",
2165
+ }
2166
+
2167
+ -- Aturan Pola Heuristik Kode Berbahaya
2168
+ local THREAT_PATTERNS = {
2169
+ {
2170
+ id = "BACKDOOR_REQUIRE",
2171
+ pattern = "require%s*%(%s*%d+%s*%)",
2172
+ severity = "HIGH",
2173
+ name = "Backdoor Numeric Require",
2174
+ description = "Memanggil modul eksternal pihak ketiga via Asset ID numerik (indikasi backdoor/remote loader).",
2175
+ },
2176
+ {
2177
+ id = "LOADSTRING_EXEC",
2178
+ pattern = "loadstring%s*%(",
2179
+ severity = "HIGH",
2180
+ name = "Dynamic Code Execution (loadstring)",
2181
+ description = "Mengeksekusi string kode dinamis yang tidak dapat diaudit.",
2182
+ },
2183
+ {
2184
+ id = "GETFENV_SETFENV",
2185
+ pattern = "[gs]etfenv%s*%(",
2186
+ severity = "HIGH",
2187
+ name = "Environment Manipulation (getfenv/setfenv)",
2188
+ description = "Memanipulasi environment global Luau untuk mengelabui analisis statis.",
2189
+ },
2190
+ {
2191
+ id = "DISCORD_WEBHOOK",
2192
+ pattern = "discord[a-z]*%.com/api/webhooks",
2193
+ severity = "HIGH",
2194
+ name = "Discord Webhook Exfiltration",
2195
+ description = "Mencoba mengirimkan data game atau informasi pemain ke webhook eksternal Discord.",
2196
+ },
2197
+ {
2198
+ id = "IP_LOGGER",
2199
+ pattern = "webhook%.site|grabify%.link|iplogger|blasze",
2200
+ severity = "HIGH",
2201
+ name = "External IP/Data Logger",
2202
+ description = "Menghubungi domain pencatat IP atau data capture pihak ketiga.",
2203
+ },
2204
+ {
2205
+ id = "FORCED_PURCHASE",
2206
+ pattern = "MarketplaceService%s*:%s*Prompt[A-Za-z]*Purchase",
2207
+ severity = "MEDIUM",
2208
+ name = "Forced Robux/Product Purchase",
2209
+ description = "Memicu dialog pembelian paksa Gamepass atau Developer Product.",
2210
+ },
2211
+ {
2212
+ id = "FORCED_TELEPORT",
2213
+ pattern = "TeleportService%s*:%s*Teleport",
2214
+ severity = "MEDIUM",
2215
+ name = "Forced Game Teleportation",
2216
+ description = "Memindahkan pemain secara paksa ke game/place lain.",
2217
+ },
2218
+ {
2219
+ id = "CHAR_TABLE_OBFUSCATION",
2220
+ pattern = "string%.char%s*%(%s*%d+%s*,%s*%d+%s*,%s*%d+",
2221
+ severity = "HIGH",
2222
+ name = "Obfuscated string.char Array",
2223
+ description = "Menyusun kode rahasia tersembunyi menggunakan deretan kode karakter ASCII.",
2224
+ },
2225
+ }
2226
+
2227
+ -- Helper: Pengecekan nama script mencurigakan
2228
+ local function checkSuspiciousName(name: string): boolean
2229
+ for _, pattern in ipairs(SUSPICIOUS_SCRIPT_NAMES) do
2230
+ if string.find(name, pattern) then
2231
+ return true
2232
+ end
2233
+ end
2234
+ return false
2235
+ end
2236
+
2237
+ -- Helper: Deteksi obfuscation byte escape (\108\111\97\100) atau hex (\x6c\x6f)
2238
+ local function checkByteObfuscation(source: string): (boolean, string?)
2239
+ local byteCount = 0
2240
+ for _ in string.gmatch(source, "\\[0-9][0-9][0-9]") do
2241
+ byteCount = byteCount + 1
2242
+ if byteCount >= 4 then
2243
+ return true, "Terdeteksi deretan escape byte ASCII (\\ddd) berulang."
2244
+ end
2245
+ end
2246
+
2247
+ local hexCount = 0
2248
+ for _ in string.gmatch(source, "\\x[0-9a-fA-F][0-9a-fA-F]") do
2249
+ hexCount = hexCount + 1
2250
+ if hexCount >= 4 then
2251
+ return true, "Terdeteksi deretan escape hex (\\xHH) berulang."
2252
+ end
2253
+ end
2254
+
2255
+ return false, nil
2256
+ end
2257
+
2258
+ -- Helper: Deteksi kode yang sengaja disembunyikan dengan spasi/baris kosong berlebih
2259
+ local function checkOffscreenHiding(source: string): (boolean, string?)
2260
+ -- Cek spasi horizontal masif (> 80 spasi berturut-turut)
2261
+ if string.find(source, " " .. string.rep(" ", 80)) then
2262
+ return true, "Kode sengaja didorong ke kanan dengan lebih dari 80 spasi agar tersembunyi dari layar Studio."
2263
+ end
2264
+
2265
+ -- Cek baris kosong masif di awal skrip (> 15 baris kosong)
2266
+ local leadingNewlines = 0
2267
+ for line in string.gmatch(source, "([^\r\n]*)\r?\n") do
2268
+ if string.gsub(line, "%s+", "") == "" then
2269
+ leadingNewlines = leadingNewlines + 1
2270
+ if leadingNewlines >= 15 then
2271
+ return true, "Terdapat lebih dari 15 baris kosong di awal skrip untuk menyembunyikan payload di baris bawah."
2272
+ end
2273
+ else
2274
+ break
2275
+ end
2276
+ end
2277
+
2278
+ return false, nil
2279
+ end
2280
+
2281
+ -- Analisis Source Code dari sebuah Script
2282
+ local function scanScriptSource(scriptInstance: Instance, source: string): { any }
2283
+ local threats = {}
2284
+ local lines = string.split(source, "\n")
2285
+
2286
+ -- 1. Scan pola heuristik baris per baris
2287
+ for lineIdx, line in ipairs(lines) do
2288
+ -- Skip baris komentar murni
2289
+ local trimmed = string.match(line, "^%s*(.-)%s*$") or ""
2290
+ if not string.sub(trimmed, 1, 2) == "--" then
2291
+ for _, rule in ipairs(THREAT_PATTERNS) do
2292
+ local s, e = string.find(line, rule.pattern)
2293
+ if s then
2294
+ local snippet = string.sub(line, math.max(1, s - 30), math.min(#line, e + 30))
2295
+ table.insert(threats, {
2296
+ severity = rule.severity,
2297
+ ruleId = rule.id,
2298
+ name = rule.name,
2299
+ scriptName = scriptInstance.Name,
2300
+ scriptPath = scriptInstance:GetFullName(),
2301
+ scriptClass = scriptInstance.ClassName,
2302
+ line = lineIdx,
2303
+ snippet = string.match(snippet, "^%s*(.-)%s*$") or snippet,
2304
+ description = rule.description,
2305
+ })
2306
+ end
2307
+ end
2308
+ end
2309
+ end
2310
+
2311
+ -- 2. Cek Obfuscation
2312
+ local isObf, obfReason = checkByteObfuscation(source)
2313
+ if isObf then
2314
+ table.insert(threats, {
2315
+ severity = "HIGH",
2316
+ ruleId = "BYTE_OBFUSCATION",
2317
+ name = "Byte / Hex Obfuscation Payload",
2318
+ scriptName = scriptInstance.Name,
2319
+ scriptPath = scriptInstance:GetFullName(),
2320
+ scriptClass = scriptInstance.ClassName,
2321
+ line = 1,
2322
+ snippet = string.sub(source, 1, 60) .. "...",
2323
+ description = obfReason or "Payload kode disamarkan menggunakan escape bytes.",
2324
+ })
2325
+ end
2326
+
2327
+ -- 3. Cek Off-screen Code Hiding
2328
+ local isHidden, hideReason = checkOffscreenHiding(source)
2329
+ if isHidden then
2330
+ table.insert(threats, {
2331
+ severity = "HIGH",
2332
+ ruleId = "OFFSCREEN_HIDING",
2333
+ name = "Off-Screen Hidden Code",
2334
+ scriptName = scriptInstance.Name,
2335
+ scriptPath = scriptInstance:GetFullName(),
2336
+ scriptClass = scriptInstance.ClassName,
2337
+ line = 1,
2338
+ snippet = "[Whitespace Padding Terdeteksi]",
2339
+ description = hideReason or "Kode disembunyikan menggunakan whitespace ekstrem.",
2340
+ })
2341
+ end
2342
+
2343
+ return threats
2344
+ end
2345
+
2346
+ -- ============================================================================
2347
+ -- PUBLIC API
2348
+ -- ============================================================================
2349
+
2350
+ -- Memindai Instance secara rekursif
2351
+ function SecurityScanner.scanInstance(rootInstance: Instance, deepScan: boolean?)
2352
+ local shouldDeepScan = (deepScan ~= false)
2353
+ local allThreats = {}
2354
+ local stats = {
2355
+ totalInstances = 1,
2356
+ models = 0,
2357
+ parts = 0,
2358
+ meshParts = 0,
2359
+ textures = 0,
2360
+ scripts = {
2361
+ total = 0,
2362
+ server = 0,
2363
+ localScript = 0,
2364
+ module = 0,
2365
+ },
2366
+ }
2367
+
2368
+ local function inspectItem(item: Instance)
2369
+ stats.totalInstances = stats.totalInstances + 1
2370
+
2371
+ if item:IsA("Model") then
2372
+ stats.models = stats.models + 1
2373
+ elseif item:IsA("MeshPart") then
2374
+ stats.meshParts = stats.meshParts + 1
2375
+ stats.parts = stats.parts + 1
2376
+ elseif item:IsA("BasePart") then
2377
+ stats.parts = stats.parts + 1
2378
+ elseif item:IsA("Texture") or item:IsA("Decal") or item:IsA("SurfaceAppearance") then
2379
+ stats.textures = stats.textures + 1
2380
+ end
2381
+
2382
+ if item:IsA("LuaSourceContainer") then
2383
+ stats.scripts.total = stats.scripts.total + 1
2384
+ if item:IsA("Script") then
2385
+ stats.scripts.server = stats.scripts.server + 1
2386
+ elseif item:IsA("LocalScript") then
2387
+ stats.scripts.localScript = stats.scripts.localScript + 1
2388
+ elseif item:IsA("ModuleScript") then
2389
+ stats.scripts.module = stats.scripts.module + 1
2390
+ end
2391
+
2392
+ -- Cek nama mencurigakan
2393
+ if checkSuspiciousName(item.Name) then
2394
+ table.insert(allThreats, {
2395
+ severity = "HIGH",
2396
+ ruleId = "SUSPICIOUS_SCRIPT_NAME",
2397
+ name = "Known Malicious Script Name Pattern",
2398
+ scriptName = item.Name,
2399
+ scriptPath = item:GetFullName(),
2400
+ scriptClass = item.ClassName,
2401
+ line = 0,
2402
+ snippet = item.Name,
2403
+ description = string.format("Nama skrip '%s' cocok dengan pola nama virus/backdoor klasik.", item.Name),
2404
+ })
2405
+ end
2406
+
2407
+ -- Deep scan isi source code
2408
+ if shouldDeepScan then
2409
+ local success, source = pcall(function()
2410
+ return (item :: any).Source
2411
+ end)
2412
+ if success and typeof(source) == "string" and #source > 0 then
2413
+ local scriptThreats = scanScriptSource(item, source)
2414
+ for _, t in ipairs(scriptThreats) do
2415
+ table.insert(allThreats, t)
2416
+ end
2417
+ end
2418
+ end
2419
+ end
2420
+ end
2421
+
2422
+ inspectItem(rootInstance)
2423
+ for _, desc in ipairs(rootInstance:GetDescendants()) do
2424
+ inspectItem(desc)
2425
+ end
2426
+
2427
+ -- Hitung Level Risiko
2428
+ local riskLevel = "SAFE"
2429
+ local highCount = 0
2430
+ local medCount = 0
2431
+
2432
+ for _, t in ipairs(allThreats) do
2433
+ if t.severity == "HIGH" then
2434
+ highCount = highCount + 1
2435
+ elseif t.severity == "MEDIUM" then
2436
+ medCount = medCount + 1
2437
+ end
2438
+ end
2439
+
2440
+ if highCount > 0 then
2441
+ riskLevel = "DANGEROUS"
2442
+ elseif medCount > 0 or stats.scripts.total > 0 then
2443
+ riskLevel = "WARNING"
2444
+ end
2445
+
2446
+ return {
2447
+ riskLevel = riskLevel,
2448
+ threats = allThreats,
2449
+ stats = stats,
2450
+ highThreats = highCount,
2451
+ mediumThreats = medCount,
2452
+ }
2453
+ end
2454
+
2455
+ -- Menghapus seluruh skrip dari sebuah instance (Sanitasi Total)
2456
+ function SecurityScanner.stripScripts(rootInstance: Instance): { any }
2457
+ local removed = {}
2458
+
2459
+ for _, desc in ipairs(rootInstance:GetDescendants()) do
2460
+ if desc:IsA("LuaSourceContainer") then
2461
+ table.insert(removed, {
2462
+ name = desc.Name,
2463
+ className = desc.ClassName,
2464
+ path = desc:GetFullName(),
2465
+ })
2466
+ pcall(function()
2467
+ desc:Destroy()
2468
+ end)
2469
+ end
2470
+ end
2471
+
2472
+ if rootInstance:IsA("LuaSourceContainer") then
2473
+ table.insert(removed, {
2474
+ name = rootInstance.Name,
2475
+ className = rootInstance.ClassName,
2476
+ path = rootInstance:GetFullName(),
2477
+ })
2478
+ pcall(function()
2479
+ rootInstance:Destroy()
2480
+ end)
2481
+ end
2482
+
2483
+ return removed
2484
+ end
2485
+
2486
+ return SecurityScanner
2487
+
1173
2488
  ]]></ProtectedString>
1174
2489
  </Properties>
1175
2490
  </Item>
@@ -1197,6 +2512,10 @@ end
1197
2512
  local currentVal = targetInstance[prop]
1198
2513
  if typeof(currentVal) == "Vector3" and type(val) == "table" and #val == 3 then
1199
2514
  targetInstance[prop] = Vector3.new(val[1], val[2], val[3])
2515
+ elseif typeof(currentVal) == "Vector2" and type(val) == "table" and #val >= 2 then
2516
+ targetInstance[prop] = Vector2.new(val[1], val[2])
2517
+ elseif typeof(currentVal) == "UDim" and type(val) == "table" and #val >= 2 then
2518
+ targetInstance[prop] = UDim.new(val[1], val[2])
1200
2519
  elseif typeof(currentVal) == "Color3" and type(val) == "table" and #val == 3 then
1201
2520
  local r, g, b = val[1], val[2], val[3]
1202
2521
  if r > 1 or g > 1 or b > 1 then
@@ -1206,6 +2525,12 @@ end
1206
2525
  end
1207
2526
  elseif typeof(currentVal) == "UDim2" and type(val) == "table" and #val == 4 then
1208
2527
  targetInstance[prop] = UDim2.new(val[1], val[2], val[3], val[4])
2528
+ elseif typeof(currentVal) == "NumberRange" and type(val) == "table" then
2529
+ targetInstance[prop] = NumberRange.new(val[1], val[2] or val[1])
2530
+ elseif typeof(currentVal) == "Rect" and type(val) == "table" and #val >= 4 then
2531
+ targetInstance[prop] = Rect.new(val[1], val[2], val[3], val[4])
2532
+ elseif typeof(currentVal) == "BrickColor" and type(val) == "string" then
2533
+ targetInstance[prop] = BrickColor.new(val)
1209
2534
  elseif typeof(currentVal) == "CFrame" and type(val) == "table" then
1210
2535
  targetInstance[prop] = CFrame.new(unpack(val))
1211
2536
  elseif typeof(currentVal) == "EnumItem" and type(val) == "string" then
@@ -1366,7 +2691,7 @@ function UIManager.init(pluginInstance, studioId)
1366
2691
 
1367
2692
  -- Version
1368
2693
  local versionLabel = Instance.new("TextLabel")
1369
- versionLabel.Text = "v2.2.0"
2694
+ versionLabel.Text = "v2.3.0"
1370
2695
  versionLabel.Font = Enum.Font.Gotham
1371
2696
  versionLabel.TextSize = 11
1372
2697
  versionLabel.TextColor3 = Color3.fromRGB(120, 120, 130)