@adiholick/nvstudio-mcp 2.2.3 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +57 -47
  2. package/agent-plugin/mcp_config.json +8 -0
  3. package/agent-plugin/plugin.json +6 -0
  4. package/agent-plugin/rules/AGENTS.md +118 -0
  5. package/agent-plugin/skills/nvstudio-animation-vfx/SKILL.md +120 -0
  6. package/agent-plugin/skills/nvstudio-assets-terrain-guide/SKILL.md +27 -0
  7. package/agent-plugin/skills/nvstudio-gui-standards/SKILL.md +419 -0
  8. package/agent-plugin/skills/nvstudio-mcp-guide/SKILL.md +83 -0
  9. package/agent-plugin/skills/nvstudio-rig-engine/SKILL.md +170 -0
  10. package/agent-plugin/skills/nvstudio-roblox-sync-guide/SKILL.md +16 -0
  11. package/dist/build-plugin.js +55 -0
  12. package/dist/index.js +841 -3
  13. package/dist/luau-validator.js +99 -34
  14. package/dist/roblox-bridge.js +152 -4
  15. package/dist/roblox-docs.js +264 -0
  16. package/dist/vfx-database.js +507 -0
  17. package/package.json +6 -4
  18. package/public/index.html +1590 -180
  19. package/studio-plugin/Main.server.lua +1 -1
  20. package/studio-plugin/animate_character.lua +36 -0
  21. package/studio-plugin/apply_vfx.lua +59 -0
  22. package/studio-plugin/batch_create_instances.lua +128 -0
  23. package/studio-plugin/batch_set_properties.lua +69 -0
  24. package/studio-plugin/edit_rig.lua +35 -0
  25. package/studio-plugin/insert_asset.lua +85 -12
  26. package/studio-plugin/nv_anim_dispatcher.lua +239 -0
  27. package/studio-plugin/nv_auto_rigger.lua +392 -0
  28. package/studio-plugin/nv_curves.lua +260 -0
  29. package/studio-plugin/nv_joint_math.lua +138 -0
  30. package/studio-plugin/nv_kinematics.lua +248 -0
  31. package/studio-plugin/nv_motion_generator.lua +279 -0
  32. package/studio-plugin/nv_rig_dispatcher.lua +319 -0
  33. package/studio-plugin/nv_vfx_serializer.lua +363 -0
  34. package/studio-plugin/nvstudio_mcp.rbxmx +3824 -60
  35. package/studio-plugin/preview_asset.lua +99 -0
  36. package/studio-plugin/scan_security.lua +81 -0
  37. package/studio-plugin/scan_vfx.lua +85 -0
  38. package/studio-plugin/scrub_timeline.lua +122 -0
  39. package/studio-plugin/security_scanner.lua +346 -0
  40. package/studio-plugin/set_properties.lua +83 -0
  41. package/studio-plugin/solve_limb_ik.lua +63 -0
  42. package/studio-plugin/ui_manager.lua +1 -1
@@ -10,6 +10,350 @@
10
10
  <int64 name="SourceAssetId">-1</int64>
11
11
  <BinaryString name="Tags"></BinaryString>
12
12
  </Properties>
13
+ <Item class="ModuleScript" referent="RBX_animate_character">
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">animate_character</string>
21
+ <int64 name="SourceAssetId">-1</int64>
22
+ <BinaryString name="Tags"></BinaryString>
23
+ <ProtectedString name="Source"><![CDATA[--!strict
24
+ -- ══════════════════════════════════════════════════════════════════════
25
+ -- Skill Module: animate_character
26
+ -- Deskripsi: Semantic Tool untuk AI Agent membuat animasi karakter halus
27
+ -- Menggunakan NV-Animation Engine (Walk, Slash, Punch, Idle, atau Custom)
28
+ -- ══════════════════════════════════════════════════════════════════════
29
+
30
+ local HttpService = game:GetService("HttpService")
31
+ local dispatcherMod = script.Parent:WaitForChild("nv_anim_dispatcher")
32
+ local Dispatcher = require(dispatcherMod)
33
+
34
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
35
+ if not targetInstance then
36
+ return { status = "error", error = "Target karakter rig untuk animate_character tidak ditemukan: " .. tostring(targetPath) }
37
+ end
38
+
39
+ local payload: { [string]: any } = {}
40
+ local ok, err = pcall(function()
41
+ if type(data) == "table" then
42
+ payload = data
43
+ else
44
+ payload = HttpService:JSONDecode(tostring(data))
45
+ end
46
+ end)
47
+
48
+ if not ok or type(payload) ~= "table" then
49
+ return { status = "error", error = "Format payload animasi tidak valid: " .. tostring(err) }
50
+ end
51
+
52
+ local action = tostring(payload.action or "walk_cycle")
53
+ local parameters = payload.parameters or {}
54
+ local keyPoses = payload.key_poses
55
+
56
+ return Dispatcher.createAnimation(targetInstance, action, parameters, keyPoses)
57
+ end
58
+
59
+ ]]></ProtectedString>
60
+ </Properties>
61
+ </Item>
62
+ <Item class="ModuleScript" referent="RBX_apply_vfx">
63
+ <Properties>
64
+ <BinaryString name="AttributesSerialize"></BinaryString>
65
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
66
+ <bool name="DefinesCapabilities">false</bool>
67
+ <bool name="Disabled">false</bool>
68
+ <Content name="LinkedSource"><null></null></Content>
69
+ <string name="Name">apply_vfx</string>
70
+ <int64 name="SourceAssetId">-1</int64>
71
+ <BinaryString name="Tags"></BinaryString>
72
+ <ProtectedString name="Source"><![CDATA[--!strict
73
+ -- ══════════════════════════════════════════════════════════════════════
74
+ -- Skill Module: apply_vfx
75
+ -- Deskripsi: Menerapkan tabel data VFX murni ke instance target di Studio
76
+ -- ══════════════════════════════════════════════════════════════════════
77
+
78
+ local HttpService = game:GetService("HttpService")
79
+ local serializerMod = script.Parent:WaitForChild("nv_vfx_serializer")
80
+ local Serializer = require(serializerMod)
81
+
82
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
83
+ if not targetInstance then
84
+ return { status = "error", error = "Target instance untuk apply_vfx tidak ditemukan: " .. tostring(targetPath) }
85
+ end
86
+
87
+ local payload = {}
88
+ local ok, err = pcall(function()
89
+ if type(data) == "table" then
90
+ payload = data
91
+ else
92
+ payload = HttpService:JSONDecode(tostring(data))
93
+ end
94
+ end)
95
+
96
+ if not ok or type(payload) ~= "table" then
97
+ return { status = "error", error = "Format payload VFX tidak valid: " .. tostring(err) }
98
+ end
99
+
100
+ -- Extract treeData (bisa di payload.data atau langsung di payload)
101
+ local treeData = payload.data or payload
102
+ if not treeData.nodes then
103
+ return { status = "error", error = "Payload tidak memiliki struktur 'nodes' yang valid untuk deserialisasi." }
104
+ end
105
+
106
+ local scale = tonumber(payload.scale) or 1
107
+ local enabled = payload.enabled
108
+
109
+ local desResult = Serializer.deserialize(treeData, targetInstance, {
110
+ scale = scale,
111
+ enabled = enabled
112
+ })
113
+
114
+ if desResult.status == "error" then
115
+ return {
116
+ status = "error",
117
+ error = "Gagal merekonstruksi VFX: " .. table.concat(desResult.errors or {}, "; ")
118
+ }
119
+ end
120
+
121
+ return {
122
+ status = "success",
123
+ result = {
124
+ message = string.format("Berhasil menerapkan VFX '%s' (%d node) ke '%s'", tostring(treeData.rootName or "VFX"), desResult.createdCount, targetInstance:GetFullName()),
125
+ createdCount = desResult.createdCount,
126
+ errors = desResult.errors
127
+ }
128
+ }
129
+ end
130
+
131
+ ]]></ProtectedString>
132
+ </Properties>
133
+ </Item>
134
+ <Item class="ModuleScript" referent="RBX_batch_create_instances">
135
+ <Properties>
136
+ <BinaryString name="AttributesSerialize"></BinaryString>
137
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
138
+ <bool name="DefinesCapabilities">false</bool>
139
+ <bool name="Disabled">false</bool>
140
+ <Content name="LinkedSource"><null></null></Content>
141
+ <string name="Name">batch_create_instances</string>
142
+ <int64 name="SourceAssetId">-1</int64>
143
+ <BinaryString name="Tags"></BinaryString>
144
+ <ProtectedString name="Source"><![CDATA[--!strict
145
+ -- ============================================================================
146
+ -- Skill Module: batch_create_instances
147
+ -- Deskripsi: Pembangun Hierarki Instance Massal & Deklaratif (Tree / Batch List)
148
+ -- ============================================================================
149
+
150
+ local HttpService = game:GetService("HttpService")
151
+
152
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
153
+ local success, config = pcall(function()
154
+ if type(data) == "table" then return data end
155
+ return HttpService:JSONDecode(tostring(data))
156
+ end)
157
+
158
+ if not success or type(config) ~= "table" then
159
+ return { status = "error", error = "Format data JSON tidak valid untuk batch_create_instances." }
160
+ end
161
+
162
+ local setPropsFunc = require(script.Parent:WaitForChild("set_properties"))
163
+ local defaultParent = targetInstance or game.Workspace
164
+
165
+ if config.target and ctx and ctx.getInstanceFromPath then
166
+ local resolved = ctx.getInstanceFromPath(config.target)
167
+ if resolved then defaultParent = resolved end
168
+ end
169
+
170
+ local createdList = {}
171
+ local errors = {}
172
+ local createdCount = 0
173
+
174
+ local function applyProps(inst: Instance, props: any)
175
+ if not props or type(props) ~= "table" or not next(props) then return end
176
+ local propJson = HttpService:JSONEncode(props)
177
+ local res = setPropsFunc(inst, propJson, ctx, inst:GetFullName())
178
+ if res and res.status == "error" then
179
+ table.insert(errors, string.format("[%s] Error properti: %s", inst.Name, tostring(res.result or res.error)))
180
+ end
181
+ end
182
+
183
+ -- 1. MODE HIERARCHICAL TREE (Pohon Bersarang)
184
+ local function buildTreeNode(parentInst: Instance, node: any): Instance?
185
+ local className = node.className or node.ClassName
186
+ if not className or type(className) ~= "string" then
187
+ table.insert(errors, "Node pohon tidak memiliki ClassName yang valid.")
188
+ return nil
189
+ end
190
+
191
+ local ok, inst = pcall(function()
192
+ local newObj = Instance.new(className)
193
+ if node.name or node.Name then
194
+ newObj.Name = tostring(node.name or node.Name)
195
+ end
196
+ newObj.Parent = parentInst
197
+ return newObj
198
+ end)
199
+
200
+ if not ok or not inst then
201
+ table.insert(errors, string.format("Gagal membuat instance '%s': %s", className, tostring(inst)))
202
+ return nil
203
+ end
204
+
205
+ createdCount = createdCount + 1
206
+ table.insert(createdList, inst:GetFullName())
207
+
208
+ -- Terapkan properti
209
+ applyProps(inst, node.properties or node.Properties)
210
+
211
+ -- Rekursif untuk seluruh child
212
+ local children = node.children or node.Children
213
+ if children and type(children) == "table" then
214
+ for _, childNode in ipairs(children) do
215
+ buildTreeNode(inst, childNode)
216
+ end
217
+ end
218
+
219
+ return inst
220
+ end
221
+
222
+ -- Eksekusi Mode Tree jika disediakan
223
+ if config.tree and type(config.tree) == "table" then
224
+ buildTreeNode(defaultParent, config.tree)
225
+ end
226
+
227
+ -- 2. MODE FLAT ARRAY (Daftar Instance Terpisah)
228
+ local instancesArray = config.instances or config.items
229
+ if instancesArray and type(instancesArray) == "table" then
230
+ for idx, item in ipairs(instancesArray) do
231
+ local parentInst = defaultParent
232
+ if item.parent and ctx and ctx.getInstanceFromPath then
233
+ local resParent = ctx.getInstanceFromPath(item.parent)
234
+ if resParent then parentInst = resParent end
235
+ end
236
+
237
+ local className = item.className or item.ClassName
238
+ if className then
239
+ local ok, inst = pcall(function()
240
+ local newObj = Instance.new(className)
241
+ if item.name or item.Name then
242
+ newObj.Name = tostring(item.name or item.Name)
243
+ end
244
+ newObj.Parent = parentInst
245
+ return newObj
246
+ end)
247
+
248
+ if ok and inst then
249
+ createdCount = createdCount + 1
250
+ table.insert(createdList, inst:GetFullName())
251
+ applyProps(inst, item.properties or item.Properties)
252
+ else
253
+ table.insert(errors, string.format("Item #%d (%s) gagal dibuat: %s", idx, tostring(className), tostring(inst)))
254
+ end
255
+ end
256
+ end
257
+ end
258
+
259
+ local status = (createdCount > 0 or #errors == 0) and "success" or "error"
260
+ return {
261
+ status = status,
262
+ result = {
263
+ totalCreated = createdCount,
264
+ created = createdList,
265
+ hasErrors = (#errors > 0),
266
+ errors = errors,
267
+ message = string.format("Berhasil membuat %d instance secara batch.", createdCount)
268
+ }
269
+ }
270
+ end
271
+
272
+ ]]></ProtectedString>
273
+ </Properties>
274
+ </Item>
275
+ <Item class="ModuleScript" referent="RBX_batch_set_properties">
276
+ <Properties>
277
+ <BinaryString name="AttributesSerialize"></BinaryString>
278
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
279
+ <bool name="DefinesCapabilities">false</bool>
280
+ <bool name="Disabled">false</bool>
281
+ <Content name="LinkedSource"><null></null></Content>
282
+ <string name="Name">batch_set_properties</string>
283
+ <int64 name="SourceAssetId">-1</int64>
284
+ <BinaryString name="Tags"></BinaryString>
285
+ <ProtectedString name="Source"><![CDATA[--!strict
286
+ -- ============================================================================
287
+ -- Skill Module: batch_set_properties
288
+ -- Deskripsi: Modifikasi Properti Massal untuk Beberapa Instance Sekaligus
289
+ -- ============================================================================
290
+
291
+ local HttpService = game:GetService("HttpService")
292
+
293
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
294
+ local success, config = pcall(function()
295
+ if type(data) == "table" then return data end
296
+ return HttpService:JSONDecode(tostring(data))
297
+ end)
298
+
299
+ if not success or type(config) ~= "table" then
300
+ return { status = "error", error = "Format data JSON tidak valid untuk batch_set_properties." }
301
+ end
302
+
303
+ local setPropsFunc = require(script.Parent:WaitForChild("set_properties"))
304
+ local updates = config.updates or config
305
+ if type(updates) ~= "table" then
306
+ return { status = "error", error = "Parameter 'updates' harus berupa array pembaruan properti." }
307
+ end
308
+
309
+ local updatedCount = 0
310
+ local successfulTargets = {}
311
+ local errors = {}
312
+
313
+ for idx, updateItem in ipairs(updates) do
314
+ local inst = targetInstance
315
+ local instPath = updateItem.target or updateItem.path
316
+ if instPath and ctx and ctx.getInstanceFromPath then
317
+ local resolved = ctx.getInstanceFromPath(instPath)
318
+ if resolved then
319
+ inst = resolved
320
+ else
321
+ table.insert(errors, string.format("Item #%d: Target path '%s' tidak ditemukan.", idx, tostring(instPath)))
322
+ continue
323
+ end
324
+ end
325
+
326
+ local props = updateItem.properties or updateItem.props
327
+ if inst and props and type(props) == "table" then
328
+ local propJson = HttpService:JSONEncode(props)
329
+ local res = setPropsFunc(inst, propJson, ctx, inst:GetFullName())
330
+ if res and res.status == "error" then
331
+ table.insert(errors, string.format("[%s]: %s", inst.Name, tostring(res.result or res.error)))
332
+ else
333
+ updatedCount = updatedCount + 1
334
+ table.insert(successfulTargets, inst:GetFullName())
335
+ end
336
+ else
337
+ table.insert(errors, string.format("Item #%d: Instance atau properties tidak valid.", idx))
338
+ end
339
+ end
340
+
341
+ local status = (updatedCount > 0 or #errors == 0) and "success" or "error"
342
+ return {
343
+ status = status,
344
+ result = {
345
+ totalUpdated = updatedCount,
346
+ updatedInstances = successfulTargets,
347
+ hasErrors = (#errors > 0),
348
+ errors = errors,
349
+ message = string.format("Berhasil memperbarui properti untuk %d instance.", updatedCount)
350
+ }
351
+ }
352
+ end
353
+
354
+ ]]></ProtectedString>
355
+ </Properties>
356
+ </Item>
13
357
  <Item class="ModuleScript" referent="RBX_create_instance">
14
358
  <Properties>
15
359
  <BinaryString name="AttributesSerialize"></BinaryString>
@@ -107,6 +451,54 @@ end
107
451
  return { status = "error", error = "Gagal menghapus instance: " .. tostring(err) }
108
452
  end
109
453
  end
454
+ ]]></ProtectedString>
455
+ </Properties>
456
+ </Item>
457
+ <Item class="ModuleScript" referent="RBX_edit_rig">
458
+ <Properties>
459
+ <BinaryString name="AttributesSerialize"></BinaryString>
460
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
461
+ <bool name="DefinesCapabilities">false</bool>
462
+ <bool name="Disabled">false</bool>
463
+ <Content name="LinkedSource"><null></null></Content>
464
+ <string name="Name">edit_rig</string>
465
+ <int64 name="SourceAssetId">-1</int64>
466
+ <BinaryString name="Tags"></BinaryString>
467
+ <ProtectedString name="Source"><![CDATA[--!strict
468
+ -- ══════════════════════════════════════════════════════════════════════
469
+ -- Skill Module: edit_rig
470
+ -- Deskripsi: Semantic Tool untuk AI Agent mengelola rig, Motor6D, Weld,
471
+ -- C0/C1 pivot compensation, auto-rigging, dan pengelasan model.
472
+ -- ══════════════════════════════════════════════════════════════════════
473
+
474
+ local HttpService = game:GetService("HttpService")
475
+ local dispatcherMod = script.Parent:WaitForChild("nv_rig_dispatcher")
476
+ local Dispatcher = require(dispatcherMod)
477
+
478
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
479
+ if not targetInstance then
480
+ return { status = "error", error = "Target instance untuk edit_rig tidak ditemukan: " .. tostring(targetPath) }
481
+ end
482
+
483
+ local payload: { [string]: any } = {}
484
+ local ok, err = pcall(function()
485
+ if type(data) == "table" then
486
+ payload = data
487
+ else
488
+ payload = HttpService:JSONDecode(tostring(data))
489
+ end
490
+ end)
491
+
492
+ if not ok or type(payload) ~= "table" then
493
+ return { status = "error", error = "Format payload edit_rig tidak valid: " .. tostring(err) }
494
+ end
495
+
496
+ local action = tostring(payload.action or "inspect")
497
+ local parameters = payload.parameters or payload
498
+
499
+ return Dispatcher.dispatch(targetInstance, action, parameters)
500
+ end
501
+
110
502
  ]]></ProtectedString>
111
503
  </Properties>
112
504
  </Item>
@@ -473,28 +865,101 @@ end
473
865
  <string name="Name">insert_asset</string>
474
866
  <int64 name="SourceAssetId">-1</int64>
475
867
  <BinaryString name="Tags"></BinaryString>
476
- <ProtectedString name="Source"><![CDATA[return function(targetInstance, data, ctx, targetPath)
477
- local InsertService = game:GetService("InsertService")
478
- local rawId = tostring(data or ""):gsub("rbxassetid://", ""):gsub("%s+", "")
868
+ <ProtectedString name="Source"><![CDATA[--!strict
869
+ -- ============================================================================
870
+ -- Skill Module: insert_asset (Upgraded with Asset Security Scanner & Script Stripper)
871
+ -- ============================================================================
872
+
873
+ local HttpService = game:GetService("HttpService")
874
+ local InsertService = game:GetService("InsertService")
875
+
876
+ local SecurityScanner = require(script.Parent:WaitForChild("security_scanner"))
877
+
878
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
879
+ local assetIdStr = ""
880
+ local stripScripts = true -- Default TRUE demi keamanan mutlak
881
+ local scanSecurity = true
882
+
883
+ if typeof(data) == "string" then
884
+ local success, parsed = pcall(function()
885
+ return HttpService:JSONDecode(data)
886
+ end)
887
+ if success and typeof(parsed) == "table" then
888
+ assetIdStr = tostring(parsed.asset_id or parsed.assetId or "")
889
+ if parsed.strip_scripts ~= nil then stripScripts = parsed.strip_scripts end
890
+ if parsed.scan_security ~= nil then scanSecurity = parsed.scan_security end
891
+ else
892
+ assetIdStr = data
893
+ end
894
+ elseif typeof(data) == "table" then
895
+ assetIdStr = tostring(data.asset_id or data.assetId or "")
896
+ if data.strip_scripts ~= nil then stripScripts = data.strip_scripts end
897
+ if data.scan_security ~= nil then scanSecurity = data.scan_security end
898
+ end
899
+
900
+ local rawId = string.gsub(assetIdStr, "rbxassetid://", "")
901
+ rawId = string.gsub(rawId, "%s+", "")
479
902
  local assetId = tonumber(rawId)
480
- if not assetId then
903
+
904
+ if not assetId or assetId <= 0 then
481
905
  return { status = "error", error = "Asset ID harus berupa angka yang valid." }
482
906
  end
483
-
907
+
484
908
  local success, model = pcall(function()
485
909
  return InsertService:LoadAsset(assetId)
486
910
  end)
487
-
488
- if success and model then
489
- if targetInstance == game or targetInstance.ClassName == "DataModel" then
490
- model.Parent = game.Workspace
911
+
912
+ if not success or not model then
913
+ return {
914
+ status = "error",
915
+ error = "Gagal memuat aset. Pastikan Asset ID benar dan model berstatus publik di Creator Store."
916
+ }
917
+ end
918
+
919
+ local securityNotes = {}
920
+ local removedScripts = {}
921
+
922
+ -- 1. Scan keamanan jika diaktifkan
923
+ if scanSecurity then
924
+ local scanResult = SecurityScanner.scanInstance(model, true)
925
+ if scanResult.riskLevel == "DANGEROUS" then
926
+ table.insert(securityNotes, string.format("⚠️ PERINGATAN: Aset ini memiliki %d ancaman BERBAHAYA (Backdoor/Obfuscation/Webhook)!", scanResult.highThreats))
927
+ end
928
+ end
929
+
930
+ -- 2. Sanitasi: Hapus skrip jika strip_scripts = true
931
+ if stripScripts then
932
+ removedScripts = SecurityScanner.stripScripts(model)
933
+ if #removedScripts > 0 then
934
+ table.insert(securityNotes, string.format("🛡️ Keamanan: %d skrip bawaan telah dihapus secara otomatis demi keamanan game Anda.", #removedScripts))
491
935
  else
492
- model.Parent = targetInstance
936
+ table.insert(securityNotes, "🛡️ Keamanan: Aset bersih, tidak ditemukan skrip berbahaya.")
493
937
  end
494
- return { status = "success", result = "Aset berhasil dimasukkan ke " .. model.Parent.Name .. "." }
495
938
  else
496
- return { status = "error", error = "Gagal memuat aset. Pastikan Asset ID benar dan akun/plugin Anda memiliki izin (Ownership/Public)." }
939
+ table.insert(securityNotes, "ℹ️ Catatan: strip_scripts = false, skrip bawaan model dipertahankan.")
940
+ end
941
+
942
+ -- 3. Masukkan model ke target
943
+ local parentTarget = targetInstance
944
+ if not parentTarget or parentTarget == game or parentTarget.ClassName == "DataModel" then
945
+ parentTarget = game.Workspace
497
946
  end
947
+
948
+ model.Parent = parentTarget
949
+
950
+ return {
951
+ status = "success",
952
+ result = {
953
+ message = string.format("Aset '%s' (ID: %d) berhasil dimasukkan ke %s.", model.Name, assetId, parentTarget.Name),
954
+ assetName = model.Name,
955
+ assetId = assetId,
956
+ parent = parentTarget:GetFullName(),
957
+ stripScriptsApplied = stripScripts,
958
+ removedScriptsCount = #removedScripts,
959
+ removedScripts = removedScripts,
960
+ securityNotes = table.concat(securityNotes, "\n"),
961
+ }
962
+ }
498
963
  end
499
964
  ]]></ProtectedString>
500
965
  </Properties>
@@ -511,7 +976,7 @@ end
511
976
  <BinaryString name="Tags"></BinaryString>
512
977
  <token name="RunContext">0</token>
513
978
  <ProtectedString name="Source"><![CDATA[-- ══════════════════════════════════════════════════════════════════════
514
- -- NVStudio MCP Plugin v2.2.2
979
+ -- NVStudio MCP Plugin v2.4.0
515
980
  -- Arsitektur: Dual-Mode WebSocket Duplex (Port 3055/ws) + Fallback Long-Polling
516
981
  -- Plugin auto-connect saat dimuat via WebSocket native, dengan fallback otomatis
517
982
  -- ke HTTP stream jika WebSocket tidak tersedia di environment Roblox Studio ini.
@@ -1320,66 +1785,2408 @@ end)
1320
1785
  ]]></ProtectedString>
1321
1786
  </Properties>
1322
1787
  </Item>
1323
- <Item class="ModuleScript" referent="RBX_patch_script_source">
1788
+ <Item class="ModuleScript" referent="RBX_nv_anim_dispatcher">
1324
1789
  <Properties>
1325
1790
  <BinaryString name="AttributesSerialize"></BinaryString>
1326
1791
  <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
1327
1792
  <bool name="DefinesCapabilities">false</bool>
1328
1793
  <bool name="Disabled">false</bool>
1329
1794
  <Content name="LinkedSource"><null></null></Content>
1330
- <string name="Name">patch_script_source</string>
1795
+ <string name="Name">nv_anim_dispatcher</string>
1331
1796
  <int64 name="SourceAssetId">-1</int64>
1332
1797
  <BinaryString name="Tags"></BinaryString>
1333
- <ProtectedString name="Source"><![CDATA[local HttpService = game:GetService("HttpService")
1334
-
1335
- return function(targetInstance, data, ctx, targetPath)
1336
- if not targetInstance:IsA("LuaSourceContainer") then
1337
- return { status = "error", error = "Target BUKAN Script (ClassName: " .. targetInstance.ClassName .. ")." }
1338
- end
1798
+ <ProtectedString name="Source"><![CDATA[--!strict
1799
+ -- ══════════════════════════════════════════════════════════════════════
1800
+ -- NVStudio Subsystem: nv_anim_dispatcher
1801
+ -- Deskripsi: Headless Modular Command Dispatcher untuk Pembuatan Animasi
1802
+ -- Mengatur inisialisasi rig, analytical IK, generasi keyframe kurva halus,
1803
+ -- dan ekspor resmi ke KeyframeSequence di Roblox Studio.
1804
+ -- ══════════════════════════════════════════════════════════════════════
1339
1805
 
1340
- local payload = nil
1341
- pcall(function()
1342
- payload = HttpService:JSONDecode(data)
1343
- end)
1806
+ local kinematicsMod = script.Parent:WaitForChild("nv_kinematics")
1807
+ local Kinematics = require(kinematicsMod)
1808
+ local curvesMod = script.Parent:WaitForChild("nv_curves")
1809
+ local Curves = require(curvesMod)
1810
+ local motionMod = script.Parent:WaitForChild("nv_motion_generator")
1811
+ local Motion = require(motionMod)
1812
+
1813
+ local Dispatcher = {}
1814
+
1815
+ -- ── 1. Helper: Temukan Rig dari Instance atau Path ────────────────────────────
1816
+ function Dispatcher.getRigInfo(targetInstance: Instance?): Kinematics.RigInfo?
1817
+ if not targetInstance then return nil end
1818
+ local model = targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model")
1819
+ if not model then return nil end
1820
+ return Kinematics.inspectRig(model)
1821
+ end
1344
1822
 
1345
- if not payload then
1346
- return { status = "error", error = "Payload data harus berupa JSON valid." }
1347
- end
1823
+ -- ── 2. Buat KeyframeSequence Resmi di Roblox ─────────────────────────────────
1824
+ function Dispatcher.buildKeyframeSequence(
1825
+ rigInfo: Kinematics.RigInfo,
1826
+ clipName: string,
1827
+ keyframes: { Motion.KeyframeData },
1828
+ options: { loop: boolean?, priority: Enum.AnimationPriority? }?
1829
+ ): KeyframeSequence
1830
+ local opts = options or {}
1831
+ local isR15 = rigInfo.isR15
1832
+
1833
+ local seq = Instance.new("KeyframeSequence")
1834
+ seq.Name = clipName
1835
+ seq.Loop = opts.loop ~= false
1836
+ seq.Priority = opts.priority or Enum.AnimationPriority.Action
1837
+
1838
+ -- Urutkan keyframes berdasarkan waktu
1839
+ table.sort(keyframes, function(a, b) return a.time < b.time end)
1840
+
1841
+ for _, kfData in ipairs(keyframes) do
1842
+ local kf = Instance.new("Keyframe")
1843
+ kf.Time = kfData.time
1844
+ kf.Name = string.format("Frame_%.2f", kfData.time)
1845
+
1846
+ -- Root Pose (HumanoidRootPart)
1847
+ local rootPose = Instance.new("Pose")
1848
+ rootPose.Name = "HumanoidRootPart"
1849
+ rootPose.Weight = 1
1850
+ rootPose.EasingStyle = Enum.PoseEasingStyle.Cubic
1851
+ rootPose.EasingDirection = Enum.PoseEasingDirection.InOut
1852
+
1853
+ local function createPose(name: string, cf: CFrame?, parent: Instance?): Pose
1854
+ local p = Instance.new("Pose")
1855
+ p.Name = name
1856
+ p.Weight = 1
1857
+ p.EasingStyle = Enum.PoseEasingStyle.Cubic
1858
+ p.EasingDirection = Enum.PoseEasingDirection.InOut
1859
+ if cf then p.CFrame = cf end
1860
+ if parent then p.Parent = parent end
1861
+ return p
1862
+ end
1348
1863
 
1349
- local currentSource = targetInstance.Source
1350
- local newSource = currentSource
1351
- local affectedLines = 0
1864
+ if isR15 then
1865
+ -- LowerTorso pose
1866
+ local lowerTorsoPose = createPose("LowerTorso", kfData.poses["Root"], nil)
1352
1867
 
1353
- -- Mode 1: Search & Replace Exact Chunk
1354
- if payload.target_content and payload.replacement_content then
1355
- local targetContent = payload.target_content
1356
- local replacementContent = payload.replacement_content
1357
- local allowMultiple = payload.allow_multiple or false
1868
+ -- UpperTorso pose
1869
+ local upperTorsoPose = createPose("UpperTorso", kfData.poses["Waist"], nil)
1358
1870
 
1359
- if targetContent == "" then
1360
- return { status = "error", error = "target_content tidak boleh berupa string kosong." }
1361
- end
1871
+ -- Head
1872
+ if kfData.poses["Neck"] then
1873
+ createPose("Head", kfData.poses["Neck"], upperTorsoPose)
1874
+ end
1362
1875
 
1363
- -- Escape magic characters for string.gsub if needed, or use string.find manually.
1364
- -- string.gsub doesn't do plain text replacement directly without escaping.
1365
- -- We'll write a plain text replace function.
1366
-
1367
- local function plainReplace(str, pattern, repl, max)
1368
- local res = ""
1369
- local startIdx = 1
1370
- local count = 0
1371
- while true do
1372
- if max and count >= max then break end
1373
- local findStart, findEnd = string.find(str, pattern, startIdx, true) -- true for plain match
1374
- if not findStart then break end
1375
-
1376
- res = res .. string.sub(str, startIdx, findStart - 1) .. repl
1377
- startIdx = findEnd + 1
1378
- count = count + 1
1379
- end
1380
- res = res .. string.sub(str, startIdx)
1381
- return res, count
1382
- end
1876
+ -- Arms
1877
+ for _, side in ipairs({ "Left", "Right" }) do
1878
+ local shoulderPose = createPose(side .. "UpperArm", kfData.poses[side .. "Shoulder"], nil)
1879
+ local elbowPose = shoulderPose
1880
+ if kfData.poses[side .. "Elbow"] then
1881
+ elbowPose = createPose(side .. "LowerArm", kfData.poses[side .. "Elbow"], shoulderPose)
1882
+ end
1883
+ if kfData.poses[side .. "Wrist"] then
1884
+ createPose(side .. "Hand", kfData.poses[side .. "Wrist"], elbowPose)
1885
+ end
1886
+ shoulderPose.Parent = upperTorsoPose
1887
+ end
1888
+
1889
+ -- Legs
1890
+ for _, side in ipairs({ "Left", "Right" }) do
1891
+ local hipPose = createPose(side .. "UpperLeg", kfData.poses[side .. "Hip"], nil)
1892
+ local kneePose = hipPose
1893
+ if kfData.poses[side .. "Knee"] then
1894
+ kneePose = createPose(side .. "LowerLeg", kfData.poses[side .. "Knee"], hipPose)
1895
+ end
1896
+ if kfData.poses[side .. "Ankle"] then
1897
+ createPose(side .. "Foot", kfData.poses[side .. "Ankle"], kneePose)
1898
+ end
1899
+ hipPose.Parent = lowerTorsoPose
1900
+ end
1901
+
1902
+ upperTorsoPose.Parent = lowerTorsoPose
1903
+ lowerTorsoPose.Parent = rootPose
1904
+ else
1905
+ -- R6 Pose Structure
1906
+ local torsoPose = createPose("Torso", kfData.poses["RootJoint"], nil)
1907
+
1908
+ if kfData.poses["Neck"] then
1909
+ createPose("Head", kfData.poses["Neck"], torsoPose)
1910
+ end
1911
+
1912
+ for _, limb in ipairs({ "Left Shoulder", "Right Shoulder", "Left Hip", "Right Hip" }) do
1913
+ if kfData.poses[limb] then
1914
+ local partName = limb == "Left Shoulder" and "Left Arm"
1915
+ or limb == "Right Shoulder" and "Right Arm"
1916
+ or limb == "Left Hip" and "Left Leg"
1917
+ or "Right Leg"
1918
+ createPose(partName, kfData.poses[limb], torsoPose)
1919
+ end
1920
+ end
1921
+
1922
+ torsoPose.Parent = rootPose
1923
+ end
1924
+
1925
+ -- Lampirkan VFX KeyframeMarkers jika ada event di frame ini
1926
+ if opts.vfx_events and type(opts.vfx_events) == "table" then
1927
+ for _, evt in ipairs(opts.vfx_events) do
1928
+ local evtTime = tonumber(evt.time) or 0
1929
+ if math.abs(evtTime - kfData.time) <= 0.08 then
1930
+ local marker = Instance.new("KeyframeMarker")
1931
+ marker.Name = evt.marker_name or "VFX"
1932
+ marker.Value = evt.value or evt.action or "Emit"
1933
+ marker.Parent = kf
1934
+ end
1935
+ end
1936
+ end
1937
+
1938
+ rootPose.Parent = kf
1939
+ kf.Parent = seq
1940
+ end
1941
+
1942
+ return seq
1943
+ end
1944
+
1945
+ -- ── 3. Eksekusi Perintah Pembuatan Animasi ────────────────────────────────────
1946
+ function Dispatcher.createAnimation(
1947
+ targetInstance: Instance,
1948
+ action: string,
1949
+ parameters: { [string]: any }?,
1950
+ keyPoses: { any }?
1951
+ ): { [string]: any }
1952
+ local rigInfo = Dispatcher.getRigInfo(targetInstance)
1953
+ if not rigInfo then
1954
+ return { status = "error", error = "Target bukan model karakter rig (R6/R15) yang valid: " .. targetInstance:GetFullName() }
1955
+ end
1956
+
1957
+ local params = parameters or {}
1958
+ local clipName = params.clipName or (action .. "_" .. (rigInfo.isR15 and "R15" or "R6"))
1959
+ local keyframes: { Motion.KeyframeData }
1960
+
1961
+ if action == "walk_cycle" or action == "walk" then
1962
+ keyframes = Motion.generateWalkCycle(rigInfo, params)
1963
+ elseif action == "melee_slash" or action == "slash" or action == "sword" then
1964
+ keyframes = Motion.generateMeleeSlash(rigInfo, params)
1965
+ elseif action == "punch" or action == "combat_punch" then
1966
+ keyframes = Motion.generatePunch(rigInfo, params)
1967
+ elseif action == "idle" or action == "idle_breath" then
1968
+ keyframes = Motion.generateIdle(rigInfo, params)
1969
+ elseif action == "custom" and keyPoses and #keyPoses > 0 then
1970
+ -- Parser custom keyposes dari input agent
1971
+ keyframes = {}
1972
+ for _, kp in ipairs(keyPoses) do
1973
+ local motorCfs: { [string]: CFrame } = {}
1974
+ if kp.poses then
1975
+ for mName, cVal in pairs(kp.poses) do
1976
+ if type(cVal) == "table" and cVal.t == "CFrame" then
1977
+ motorCfs[mName] = CFrame.new(table.unpack(cVal.v))
1978
+ elseif type(cVal) == "table" and cVal.angles then
1979
+ local a = cVal.angles
1980
+ motorCfs[mName] = CFrame.Angles(math.rad(a[1] or 0), math.rad(a[2] or 0), math.rad(a[3] or 0))
1981
+ end
1982
+ end
1983
+ end
1984
+ table.insert(keyframes, {
1985
+ time = tonumber(kp.time) or 0,
1986
+ poses = motorCfs
1987
+ })
1988
+ end
1989
+ else
1990
+ return {
1991
+ status = "error",
1992
+ error = "Action tidak dikenal: '" .. tostring(action) .. "'. Pilihan: walk_cycle, melee_slash, punch, idle_breath, custom."
1993
+ }
1994
+ end
1995
+
1996
+ -- Simpan KeyframeSequence ke AnimSaves pada Rig
1997
+ local animSaves = rigInfo.model:FindFirstChild("AnimSaves")
1998
+ if not animSaves then
1999
+ animSaves = Instance.new("Model")
2000
+ animSaves.Name = "AnimSaves"
2001
+ animSaves.Parent = rigInfo.model
2002
+ end
2003
+
2004
+ local kfSeq = Dispatcher.buildKeyframeSequence(rigInfo, clipName, keyframes, {
2005
+ loop = params.loop,
2006
+ priority = Enum.AnimationPriority.Action,
2007
+ vfx_events = params.vfx_events
2008
+ })
2009
+ kfSeq.Parent = animSaves
2010
+
2011
+ -- Terapkan pose pertama ke karakter di Studio untuk live feedback
2012
+ if keyframes[1] and keyframes[1].poses then
2013
+ for mName, cf in pairs(keyframes[1].poses) do
2014
+ local m = rigInfo.motors[mName]
2015
+ if m then
2016
+ pcall(function() m.C0 = cf end)
2017
+ end
2018
+ end
2019
+ end
2020
+
2021
+ return {
2022
+ status = "success",
2023
+ result = {
2024
+ clipName = clipName,
2025
+ action = action,
2026
+ rigType = rigInfo.isR15 and "R15" or "R6",
2027
+ keyframeCount = #keyframes,
2028
+ duration = keyframes[#keyframes].time,
2029
+ savedPath = kfSeq:GetFullName(),
2030
+ message = string.format("Animasi '%s' berhasil dibuat dan disimpan ke '%s'", clipName, kfSeq:GetFullName())
2031
+ }
2032
+ }
2033
+ end
2034
+
2035
+ return Dispatcher
2036
+
2037
+ ]]></ProtectedString>
2038
+ </Properties>
2039
+ </Item>
2040
+ <Item class="ModuleScript" referent="RBX_nv_auto_rigger">
2041
+ <Properties>
2042
+ <BinaryString name="AttributesSerialize"></BinaryString>
2043
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
2044
+ <bool name="DefinesCapabilities">false</bool>
2045
+ <bool name="Disabled">false</bool>
2046
+ <Content name="LinkedSource"><null></null></Content>
2047
+ <string name="Name">nv_auto_rigger</string>
2048
+ <int64 name="SourceAssetId">-1</int64>
2049
+ <BinaryString name="Tags"></BinaryString>
2050
+ <ProtectedString name="Source"><![CDATA[--!strict
2051
+ -- ══════════════════════════════════════════════════════════════════════
2052
+ -- NVStudio Subsystem: nv_auto_rigger
2053
+ -- Deskripsi: Mesin Auto-Rigging Deterministik & Heuristik Geometris
2054
+ -- Membangun struktur rig lengkap untuk Avatar R6, R15, Senjata/Prop,
2055
+ -- dan Assembly Model secara instan dengan zero-dislocation math.
2056
+ -- ══════════════════════════════════════════════════════════════════════
2057
+
2058
+ local jointMathMod = script.Parent:WaitForChild("nv_joint_math")
2059
+ local JointMath = require(jointMathMod)
2060
+
2061
+ local AutoRigger = {}
2062
+
2063
+ -- ── Helper: Dapatkan atau buat Motor6D ─────────────────────────────────────────
2064
+ local function createOrUpdateMotor(name: string, part0: BasePart, part1: BasePart, parent: Instance?): Motor6D
2065
+ part1.Anchored = false
2066
+ local existing = part0:FindFirstChild(name) or part1:FindFirstChild(name)
2067
+ local motor: Motor6D
2068
+ if existing and existing:IsA("Motor6D") then
2069
+ motor = existing
2070
+ else
2071
+ motor = Instance.new("Motor6D")
2072
+ motor.Name = name
2073
+ end
2074
+
2075
+ motor.Part0 = part0
2076
+ motor.Part1 = part1
2077
+ local c0, c1 = JointMath.computeInitialOffsets(part0, part1)
2078
+ motor.C0 = c0
2079
+ motor.C1 = c1
2080
+ motor.Parent = parent or part0
2081
+ return motor
2082
+ end
2083
+
2084
+ -- ── 1. Auto-Rig R6 Avatar ─────────────────────────────────────────────────────
2085
+ function AutoRigger.rigR6(model: Model): { [string]: any }
2086
+ assert(model and model:IsA("Model"), "Target harus berupa Model")
2087
+
2088
+ local parts: { [string]: BasePart } = {}
2089
+ for _, desc in ipairs(model:GetDescendants()) do
2090
+ if desc:IsA("BasePart") then
2091
+ parts[desc.Name] = desc
2092
+ end
2093
+ end
2094
+
2095
+ local torso = parts["Torso"] or parts["UpperTorso"]
2096
+ if not torso then
2097
+ return { status = "error", error = "Model tidak memiliki part 'Torso' untuk R6 rig." }
2098
+ end
2099
+
2100
+ local rootPart = parts["HumanoidRootPart"]
2101
+ if not rootPart then
2102
+ -- Buat HumanoidRootPart transparan jika belum ada
2103
+ rootPart = Instance.new("Part")
2104
+ rootPart.Name = "HumanoidRootPart"
2105
+ rootPart.Size = Vector3.new(2, 2, 1)
2106
+ rootPart.Transparency = 1
2107
+ rootPart.CanCollide = false
2108
+ rootPart.CFrame = torso.CFrame
2109
+ rootPart.Parent = model
2110
+ parts["HumanoidRootPart"] = rootPart
2111
+ model.PrimaryPart = rootPart
2112
+ end
2113
+
2114
+ local created = {}
2115
+
2116
+ -- 1. RootJoint: HumanoidRootPart -> Torso
2117
+ local rootJoint = createOrUpdateMotor("RootJoint", rootPart, torso, rootPart)
2118
+ table.insert(created, "RootJoint")
2119
+
2120
+ -- 2. Neck: Torso -> Head
2121
+ if parts["Head"] then
2122
+ createOrUpdateMotor("Neck", torso, parts["Head"], torso)
2123
+ table.insert(created, "Neck")
2124
+ end
2125
+
2126
+ -- 3. Shoulders: Torso -> Arms
2127
+ local lArm = parts["Left Arm"] or parts["LeftArm"]
2128
+ if lArm then
2129
+ createOrUpdateMotor("Left Shoulder", torso, lArm, torso)
2130
+ table.insert(created, "Left Shoulder")
2131
+ end
2132
+
2133
+ local rArm = parts["Right Arm"] or parts["RightArm"]
2134
+ if rArm then
2135
+ createOrUpdateMotor("Right Shoulder", torso, rArm, torso)
2136
+ table.insert(created, "Right Shoulder")
2137
+ end
2138
+
2139
+ -- 4. Hips: Torso -> Legs
2140
+ local lLeg = parts["Left Leg"] or parts["LeftLeg"]
2141
+ if lLeg then
2142
+ createOrUpdateMotor("Left Hip", torso, lLeg, torso)
2143
+ table.insert(created, "Left Hip")
2144
+ end
2145
+
2146
+ local rLeg = parts["Right Leg"] or parts["RightLeg"]
2147
+ if rLeg then
2148
+ createOrUpdateMotor("Right Hip", torso, rLeg, torso)
2149
+ table.insert(created, "Right Hip")
2150
+ end
2151
+
2152
+ -- Pastikan Humanoid bertipe R6
2153
+ local humanoid = model:FindFirstChildOfClass("Humanoid")
2154
+ if humanoid then
2155
+ humanoid.RigType = Enum.HumanoidRigType.R6
2156
+ end
2157
+
2158
+ return {
2159
+ status = "success",
2160
+ rigType = "R6",
2161
+ jointsCreated = #created,
2162
+ jointNames = created,
2163
+ rootPart = rootPart.Name
2164
+ }
2165
+ end
2166
+
2167
+ -- ── 2. Auto-Rig R15 Avatar ────────────────────────────────────────────────────
2168
+ function AutoRigger.rigR15(model: Model): { [string]: any }
2169
+ assert(model and model:IsA("Model"), "Target harus berupa Model")
2170
+
2171
+ local parts: { [string]: BasePart } = {}
2172
+ for _, desc in ipairs(model:GetDescendants()) do
2173
+ if desc:IsA("BasePart") then
2174
+ parts[desc.Name] = desc
2175
+ end
2176
+ end
2177
+
2178
+ local lowerTorso = parts["LowerTorso"]
2179
+ local upperTorso = parts["UpperTorso"]
2180
+ if not lowerTorso or not upperTorso then
2181
+ return { status = "error", error = "Model tidak memiliki 'LowerTorso' dan 'UpperTorso' untuk R15 rig." }
2182
+ end
2183
+
2184
+ local rootPart = parts["HumanoidRootPart"]
2185
+ if not rootPart then
2186
+ rootPart = Instance.new("Part")
2187
+ rootPart.Name = "HumanoidRootPart"
2188
+ rootPart.Size = Vector3.new(2, 2, 1)
2189
+ rootPart.Transparency = 1
2190
+ rootPart.CanCollide = false
2191
+ rootPart.CFrame = lowerTorso.CFrame
2192
+ rootPart.Parent = model
2193
+ parts["HumanoidRootPart"] = rootPart
2194
+ model.PrimaryPart = rootPart
2195
+ end
2196
+
2197
+ local r15Hierarchy = {
2198
+ { name = "Root", p0 = "HumanoidRootPart", p1 = "LowerTorso" },
2199
+ { name = "Waist", p0 = "LowerTorso", p1 = "UpperTorso" },
2200
+ { name = "Neck", p0 = "UpperTorso", p1 = "Head" },
2201
+
2202
+ -- Left Arm Chain
2203
+ { name = "LeftShoulder", p0 = "UpperTorso", p1 = "LeftUpperArm" },
2204
+ { name = "LeftElbow", p0 = "LeftUpperArm", p1 = "LeftLowerArm" },
2205
+ { name = "LeftWrist", p0 = "LeftLowerArm", p1 = "LeftHand" },
2206
+
2207
+ -- Right Arm Chain
2208
+ { name = "RightShoulder", p0 = "UpperTorso", p1 = "RightUpperArm" },
2209
+ { name = "RightElbow", p0 = "RightUpperArm", p1 = "RightLowerArm" },
2210
+ { name = "RightWrist", p0 = "RightLowerArm", p1 = "RightHand" },
2211
+
2212
+ -- Left Leg Chain
2213
+ { name = "LeftHip", p0 = "LowerTorso", p1 = "LeftUpperLeg" },
2214
+ { name = "LeftKnee", p0 = "LeftUpperLeg", p1 = "LeftLowerLeg" },
2215
+ { name = "LeftAnkle", p0 = "LeftLowerLeg", p1 = "LeftFoot" },
2216
+
2217
+ -- Right Leg Chain
2218
+ { name = "RightHip", p0 = "LowerTorso", p1 = "RightUpperLeg" },
2219
+ { name = "RightKnee", p0 = "RightUpperLeg", p1 = "RightLowerLeg" },
2220
+ { name = "RightAnkle", p0 = "RightLowerLeg", p1 = "RightFoot" }
2221
+ }
2222
+
2223
+ local created = {}
2224
+ for _, item in ipairs(r15Hierarchy) do
2225
+ local p0 = parts[item.p0]
2226
+ local p1 = parts[item.p1]
2227
+ if p0 and p1 then
2228
+ createOrUpdateMotor(item.name, p0, p1, p0)
2229
+ table.insert(created, item.name)
2230
+ end
2231
+ end
2232
+
2233
+ local humanoid = model:FindFirstChildOfClass("Humanoid")
2234
+ if humanoid then
2235
+ humanoid.RigType = Enum.HumanoidRigType.R15
2236
+ end
2237
+
2238
+ return {
2239
+ status = "success",
2240
+ rigType = "R15",
2241
+ jointsCreated = #created,
2242
+ jointNames = created,
2243
+ rootPart = rootPart.Name
2244
+ }
2245
+ end
2246
+
2247
+ -- ── 3. Rig Weapon / Tool ke Karakter ──────────────────────────────────────────
2248
+ function AutoRigger.rigWeapon(characterModel: Model, weaponInstance: Instance, targetLimbName: string?): { [string]: any }
2249
+ assert(characterModel and characterModel:IsA("Model"), "Character target harus berupa Model")
2250
+ assert(weaponInstance and weaponInstance:IsA("Instance"), "Weapon target tidak valid")
2251
+
2252
+ local parts: { [string]: BasePart } = {}
2253
+ for _, desc in ipairs(characterModel:GetDescendants()) do
2254
+ if desc:IsA("BasePart") then
2255
+ parts[desc.Name] = desc
2256
+ end
2257
+ end
2258
+
2259
+ local limb = targetLimbName and parts[targetLimbName]
2260
+ if not limb then
2261
+ limb = parts["RightHand"] or parts["Right Arm"] or parts["RightArm"] or parts["HumanoidRootPart"]
2262
+ end
2263
+ if not limb then
2264
+ return { status = "error", error = "Tidak dapat menemukan anggota tubuh target (RightHand / Right Arm) pada karakter." }
2265
+ end
2266
+
2267
+ -- Cari handle pada senjata
2268
+ local weaponHandle: BasePart? = nil
2269
+ if weaponInstance:IsA("BasePart") then
2270
+ weaponHandle = weaponInstance
2271
+ elseif weaponInstance:IsA("Model") or weaponInstance:IsA("Tool") then
2272
+ weaponHandle = (weaponInstance :: any).PrimaryPart
2273
+ or weaponInstance:FindFirstChild("Handle") :: BasePart?
2274
+ or weaponInstance:FindFirstChildWhichIsA("BasePart")
2275
+ end
2276
+
2277
+ if not weaponHandle then
2278
+ return { status = "error", error = "Senjata tidak memiliki BasePart atau Handle." }
2279
+ end
2280
+
2281
+ -- 1. Buat Motor6D penaut karakter ke Handle
2282
+ local weaponMotor = createOrUpdateMotor("WeaponGrip", limb, weaponHandle, limb)
2283
+
2284
+ -- 2. Las seluruh bagian senjata lain ke Handle
2285
+ local weldedCount = 0
2286
+ if weaponInstance ~= weaponHandle then
2287
+ for _, desc in ipairs(weaponInstance:GetDescendants()) do
2288
+ if desc:IsA("BasePart") and desc ~= weaponHandle then
2289
+ desc.Anchored = false
2290
+ local weld = desc:FindFirstChild("WeldToHandle") or Instance.new("Weld")
2291
+ weld.Name = "WeldToHandle"
2292
+ weld.Part0 = weaponHandle
2293
+ weld.Part1 = desc
2294
+ weld.C0 = weaponHandle.CFrame:ToObjectSpace(desc.CFrame)
2295
+ weld.C1 = CFrame.identity
2296
+ weld.Parent = weaponHandle
2297
+ weldedCount += 1
2298
+ end
2299
+ end
2300
+ end
2301
+
2302
+ return {
2303
+ status = "success",
2304
+ attachedTo = limb.Name,
2305
+ handleName = weaponHandle.Name,
2306
+ weldedComponents = weldedCount,
2307
+ message = string.format("Senjata '%s' berhasil dirig ke '%s' (%d part dilas)", weaponInstance.Name, limb.Name, weldedCount)
2308
+ }
2309
+ end
2310
+
2311
+ -- ── 4. Weld Assembly (Props, Armor, Kendaraan) ────────────────────────────────
2312
+ function AutoRigger.weldAssembly(container: Instance, primaryPart: BasePart?): { [string]: any }
2313
+ assert(container and container:IsA("Instance"), "Container tidak valid")
2314
+
2315
+ local rootPart: BasePart? = primaryPart
2316
+ if not rootPart and container:IsA("Model") then
2317
+ rootPart = container.PrimaryPart
2318
+ end
2319
+
2320
+ local allParts: { BasePart } = {}
2321
+ for _, desc in ipairs(container:GetDescendants()) do
2322
+ if desc:IsA("BasePart") then
2323
+ table.insert(allParts, desc)
2324
+ end
2325
+ end
2326
+ if container:IsA("BasePart") then
2327
+ table.insert(allParts, container)
2328
+ end
2329
+
2330
+ if #allParts == 0 then
2331
+ return { status = "error", error = "Tidak ada BasePart di dalam kontainer untuk dilas." }
2332
+ end
2333
+
2334
+ -- Jika belum ada rootPart, tentukan berdasarkan volume terbesar
2335
+ if not rootPart then
2336
+ local maxVol = -1
2337
+ for _, p in ipairs(allParts) do
2338
+ local vol = p.Size.X * p.Size.Y * p.Size.Z
2339
+ if vol > maxVol then
2340
+ maxVol = vol
2341
+ rootPart = p
2342
+ end
2343
+ end
2344
+ end
2345
+
2346
+ if not rootPart then
2347
+ return { status = "error", error = "Gagal menentukan Root Part untuk pengelasan." }
2348
+ end
2349
+
2350
+ local weldsCreated = 0
2351
+ for _, p in ipairs(allParts) do
2352
+ if p ~= rootPart then
2353
+ p.Anchored = false
2354
+ local w = p:FindFirstChild("AssemblyWeld") or Instance.new("Weld")
2355
+ w.Name = "AssemblyWeld"
2356
+ w.Part0 = rootPart
2357
+ w.Part1 = p
2358
+ w.C0 = rootPart.CFrame:ToObjectSpace(p.CFrame)
2359
+ w.C1 = CFrame.identity
2360
+ w.Parent = rootPart
2361
+ weldsCreated += 1
2362
+ end
2363
+ end
2364
+
2365
+ return {
2366
+ status = "success",
2367
+ rootPart = rootPart.Name,
2368
+ weldsCreated = weldsCreated,
2369
+ message = string.format("Berhasil mengelas %d part ke Root '%s'", weldsCreated, rootPart.Name)
2370
+ }
2371
+ end
2372
+
2373
+ -- ── 5. Spatial Geometric Limb Analyzer (Heuristik Bounding Box) ───────────────
2374
+ -- Menganalisis model dengan nama part acak/impor dan mengklasifikasikan anggota tubuh
2375
+ function AutoRigger.discoverLimbsBySpatialGeometry(model: Model): { [string]: BasePart }
2376
+ local parts: { BasePart } = {}
2377
+ for _, desc in ipairs(model:GetDescendants()) do
2378
+ if desc:IsA("BasePart") then
2379
+ table.insert(parts, desc)
2380
+ end
2381
+ end
2382
+
2383
+ if #parts == 0 then return {} end
2384
+
2385
+ -- 1. Hitung bounding box pusat model
2386
+ local cframe, size = model:GetBoundingBox()
2387
+ local center = cframe.Position
2388
+
2389
+ -- 2. Temukan torso (part dengan volume besar di sekitar pusat vertikal)
2390
+ local bestTorso: BasePart = parts[1]
2391
+ local bestScore = -1e9
2392
+
2393
+ for _, p in ipairs(parts) do
2394
+ local vol = p.Size.X * p.Size.Y * p.Size.Z
2395
+ local distToCenter = (p.Position - center).Magnitude
2396
+ local score = vol / (distToCenter + 1)
2397
+ if score > bestScore then
2398
+ bestScore = score
2399
+ bestTorso = p
2400
+ end
2401
+ end
2402
+
2403
+ local identified: { [string]: BasePart } = {
2404
+ Torso = bestTorso
2405
+ }
2406
+
2407
+ local torsoPos = bestTorso.Position
2408
+
2409
+ -- 3. Klasifikasi part lainnya relatif terhadap Torso
2410
+ for _, p in ipairs(parts) do
2411
+ if p ~= bestTorso then
2412
+ local relPos = bestTorso.CFrame:PointToObjectSpace(p.Position)
2413
+
2414
+ if relPos.Y > bestTorso.Size.Y * 0.5 then
2415
+ -- Bagian atas -> Head
2416
+ if not identified["Head"] or p.Position.Y > identified["Head"].Position.Y then
2417
+ identified["Head"] = p
2418
+ end
2419
+ elseif relPos.Y < -bestTorso.Size.Y * 0.3 then
2420
+ -- Bagian bawah -> Legs
2421
+ if relPos.X < 0 then
2422
+ identified["Left Leg"] = p
2423
+ else
2424
+ identified["Right Leg"] = p
2425
+ end
2426
+ else
2427
+ -- Bagian samping -> Arms
2428
+ if relPos.X < 0 then
2429
+ identified["Left Arm"] = p
2430
+ else
2431
+ identified["Right Arm"] = p
2432
+ end
2433
+ end
2434
+ end
2435
+ end
2436
+
2437
+ return identified
2438
+ end
2439
+
2440
+ return AutoRigger
2441
+
2442
+ ]]></ProtectedString>
2443
+ </Properties>
2444
+ </Item>
2445
+ <Item class="ModuleScript" referent="RBX_nv_curves">
2446
+ <Properties>
2447
+ <BinaryString name="AttributesSerialize"></BinaryString>
2448
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
2449
+ <bool name="DefinesCapabilities">false</bool>
2450
+ <bool name="Disabled">false</bool>
2451
+ <Content name="LinkedSource"><null></null></Content>
2452
+ <string name="Name">nv_curves</string>
2453
+ <int64 name="SourceAssetId">-1</int64>
2454
+ <BinaryString name="Tags"></BinaryString>
2455
+ <ProtectedString name="Source"><![CDATA[--!strict
2456
+ --!native
2457
+ -- ══════════════════════════════════════════════════════════════════════
2458
+ -- NVStudio Subsystem: nv_curves
2459
+ -- Deskripsi: Mesin Kurva Cubic Bezier, Easing Functions & Quaternion SLERP
2460
+ -- Menghitung interpolasi waktu dan rotasi sendi secara ultra-halus
2461
+ -- layaknya kurva Graph Editor animator profesional.
2462
+ -- ══════════════════════════════════════════════════════════════════════
2463
+
2464
+ local Curves = {}
2465
+ local PI = math.pi
2466
+ local HALF_PI = math.pi * 0.5
2467
+ local TAU = math.pi * 2
2468
+ local DEFAULT_OVERSHOOT = 1.70158
2469
+
2470
+ -- ── 1. Cubic Bezier Solver ───────────────────────────────────────────────────
2471
+ function Curves.bezierParameter(alpha: number, x1: number, x2: number): number
2472
+ if alpha <= 0 then return 0 end
2473
+ if alpha >= 1 then return 1 end
2474
+
2475
+ x1 = math.clamp(x1, 0, 1)
2476
+ x2 = math.clamp(x2, 0, 1)
2477
+
2478
+ local lo, hi, u = 0, 1, alpha
2479
+ for _ = 1, 32 do
2480
+ local v = 1 - u
2481
+ local x = 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u
2482
+ local delta = x - alpha
2483
+ if math.abs(delta) <= 1e-7 then
2484
+ break
2485
+ end
2486
+
2487
+ if delta < 0 then
2488
+ lo = u
2489
+ else
2490
+ hi = u
2491
+ end
2492
+
2493
+ local deriv = 3 * v * v * x1 + 6 * v * u * (x2 - x1) + 3 * u * u * (1 - x2)
2494
+ local cand = deriv > 1e-7 and (u - delta / deriv) or -1
2495
+ u = (cand > lo and cand < hi) and cand or (lo + hi) * 0.5
2496
+ end
2497
+
2498
+ return u
2499
+ end
2500
+
2501
+ function Curves.cubic(a: number, b: number, c: number, d: number, u: number): number
2502
+ local v = 1 - u
2503
+ return a * (v * v * v) + b * (3 * v * v * u) + c * (3 * v * u * u) + d * (u * u * u)
2504
+ end
2505
+
2506
+ function Curves.bezierAlpha(t: number, x1: number?, y1: number?, x2: number?, y2: number?): number
2507
+ if t <= 0 then return 0 end
2508
+ if t >= 1 then return 1 end
2509
+
2510
+ local px1 = x1 or 0.42
2511
+ local py1 = y1 or 0.0
2512
+ local px2 = x2 or 0.58
2513
+ local py2 = y2 or 1.0
2514
+
2515
+ local u = Curves.bezierParameter(t, px1, px2)
2516
+ return Curves.cubic(0, py1, py2, 1, u)
2517
+ end
2518
+
2519
+ -- ── 2. Standard Easing Functions ─────────────────────────────────────────────
2520
+ local EasingTable: { [string]: (t: number) -> number } = {
2521
+ Linear = function(t) return t end,
2522
+
2523
+ SineIn = function(t) return 1 - math.cos(t * HALF_PI) end,
2524
+ SineOut = function(t) return math.sin(t * HALF_PI) end,
2525
+ SineInOut = function(t) return -(math.cos(PI * t) - 1) * 0.5 end,
2526
+
2527
+ QuadIn = function(t) return t * t end,
2528
+ QuadOut = function(t) return t * (2 - t) end,
2529
+ QuadInOut = function(t) return t < 0.5 and (2 * t * t) or (-1 + (4 - 2 * t) * t) end,
2530
+
2531
+ CubicIn = function(t) return t * t * t end,
2532
+ CubicOut = function(t) local f = t - 1 return f * f * f + 1 end,
2533
+ CubicInOut = function(t) return t < 0.5 and (4 * t * t * t) or ((t - 1) * (2 * t - 2) * (2 * t - 2) + 1) end,
2534
+
2535
+ QuartIn = function(t) return t * t * t * t end,
2536
+ QuartOut = function(t) local f = t - 1 return 1 - f * f * f * f end,
2537
+ QuartInOut = function(t) local f = t - 1 return t < 0.5 and (8 * t * t * t * t) or (1 - 8 * f * f * f * f) end,
2538
+
2539
+ ExpoIn = function(t) return t == 0 and 0 or math.pow(2, 10 * (t - 1)) end,
2540
+ ExpoOut = function(t) return t == 1 and 1 or (1 - math.pow(2, -10 * t)) end,
2541
+ ExpoInOut = function(t)
2542
+ if t == 0 then return 0 end
2543
+ if t == 1 then return 1 end
2544
+ if t < 0.5 then return math.pow(2, 20 * t - 10) * 0.5 end
2545
+ return (2 - math.pow(2, -20 * t + 10)) * 0.5
2546
+ end,
2547
+
2548
+ BackIn = function(t) local s = DEFAULT_OVERSHOOT return t * t * ((s + 1) * t - s) end,
2549
+ BackOut = function(t) local s = DEFAULT_OVERSHOOT local f = t - 1 return f * f * ((s + 1) * f + s) + 1 end,
2550
+ BackInOut = function(t)
2551
+ local s = DEFAULT_OVERSHOOT * 1.525
2552
+ local f = t * 2
2553
+ if f < 1 then return 0.5 * (f * f * ((s + 1) * f - s)) end
2554
+ f = f - 2
2555
+ return 0.5 * (f * f * ((s + 1) * f + s) + 2)
2556
+ end,
2557
+
2558
+ BounceOut = function(t)
2559
+ if t < (1 / 2.75) then
2560
+ return 7.5625 * t * t
2561
+ elseif t < (2 / 2.75) then
2562
+ local f = t - (1.5 / 2.75)
2563
+ return 7.5625 * f * f + 0.75
2564
+ elseif t < (2.5 / 2.75) then
2565
+ local f = t - (2.25 / 2.75)
2566
+ return 7.5625 * f * f + 0.9375
2567
+ else
2568
+ local f = t - (2.625 / 2.75)
2569
+ return 7.5625 * f * f + 0.984375
2570
+ end
2571
+ end,
2572
+ BounceIn = function(t)
2573
+ local fn = nil
2574
+ -- forward declaration
2575
+ return 1 - (function(x)
2576
+ if x < (1 / 2.75) then return 7.5625 * x * x
2577
+ elseif x < (2 / 2.75) then local f = x - (1.5 / 2.75) return 7.5625 * f * f + 0.75
2578
+ elseif x < (2.5 / 2.75) then local f = x - (2.25 / 2.75) return 7.5625 * f * f + 0.9375
2579
+ else local f = x - (2.625 / 2.75) return 7.5625 * f * f + 0.984375 end
2580
+ end)(1 - t)
2581
+ end,
2582
+ BounceInOut = function(t)
2583
+ local bounceOut = function(x: number): number
2584
+ if x < (1 / 2.75) then return 7.5625 * x * x
2585
+ elseif x < (2 / 2.75) then local f = x - (1.5 / 2.75) return 7.5625 * f * f + 0.75
2586
+ elseif x < (2.5 / 2.75) then local f = x - (2.25 / 2.75) return 7.5625 * f * f + 0.9375
2587
+ else local f = x - (2.625 / 2.75) return 7.5625 * f * f + 0.984375 end
2588
+ end
2589
+ if t < 0.5 then
2590
+ return (1 - bounceOut(1 - t * 2)) * 0.5
2591
+ end
2592
+ return bounceOut(t * 2 - 1) * 0.5 + 0.5
2593
+ end,
2594
+
2595
+ ElasticOut = function(t)
2596
+ if t == 0 then return 0 end
2597
+ if t == 1 then return 1 end
2598
+ local p = 0.3
2599
+ local s = p / 4
2600
+ return math.pow(2, -10 * t) * math.sin((t - s) * TAU / p) + 1
2601
+ end,
2602
+ ElasticIn = function(t)
2603
+ if t == 0 then return 0 end
2604
+ if t == 1 then return 1 end
2605
+ local p = 0.3
2606
+ local s = p / 4
2607
+ local f = t - 1
2608
+ return -(math.pow(2, 10 * f) * math.sin((f - s) * TAU / p))
2609
+ end,
2610
+ ElasticInOut = function(t)
2611
+ if t == 0 then return 0 end
2612
+ if t == 1 then return 1 end
2613
+ local p = 0.3 * 1.5
2614
+ local s = p / 4
2615
+ local f = t * 2 - 1
2616
+ if f < 0 then
2617
+ return -0.5 * (math.pow(2, 10 * f) * math.sin((f - s) * TAU / p))
2618
+ end
2619
+ return math.pow(2, -10 * f) * math.sin((f - s) * TAU / p) * 0.5 + 1
2620
+ end
2621
+ }
2622
+
2623
+ function Curves.evaluate(easingName: string?, t: number): number
2624
+ t = math.clamp(t, 0, 1)
2625
+ local name = easingName or "QuadInOut"
2626
+ local fn = EasingTable[name]
2627
+ if fn then
2628
+ return fn(t)
2629
+ end
2630
+ return t
2631
+ end
2632
+
2633
+ -- ── 3. Quaternion SLERP (Spherical Linear Interpolation) ──────────────────────
2634
+ -- Representasi Quaternion: { x, y, z, w }
2635
+ type Quat = { x: number, y: number, z: number, w: number }
2636
+
2637
+ local function cframeToQuat(cf: CFrame): (Vector3, Quat)
2638
+ local _, _, _, m00, m01, m02, m10, m11, m12, m20, m21, m22 = cf:GetComponents()
2639
+ local trace = m00 + m11 + m22
2640
+ local q: Quat
2641
+ if trace > 0 then
2642
+ local s = math.sqrt(trace + 1) * 2
2643
+ q = { x = (m21 - m12) / s, y = (m02 - m20) / s, z = (m10 - m01) / s, w = 0.25 * s }
2644
+ elseif (m00 > m11) and (m00 > m22) then
2645
+ local s = math.sqrt(1 + m00 - m11 - m22) * 2
2646
+ q = { x = 0.25 * s, y = (m01 + m10) / s, z = (m02 + m20) / s, w = (m21 - m12) / s }
2647
+ elseif m11 > m22 then
2648
+ local s = math.sqrt(1 + m11 - m00 - m22) * 2
2649
+ q = { x = (m01 + m10) / s, y = 0.25 * s, z = (m12 + m21) / s, w = (m02 - m20) / s }
2650
+ else
2651
+ local s = math.sqrt(1 + m22 - m00 - m11) * 2
2652
+ q = { x = (m02 + m20) / s, y = (m12 + m21) / s, z = 0.25 * s, w = (m10 - m01) / s }
2653
+ end
2654
+ return cf.Position, q
2655
+ end
2656
+
2657
+ local function quatToCFrame(pos: Vector3, q: Quat): CFrame
2658
+ local x, y, z, w = q.x, q.y, q.z, q.w
2659
+ local xx, yy, zz = x * x, y * y, z * z
2660
+ local xy, xz, yz = x * y, x * z, y * z
2661
+ local wx, wy, wz = w * x, w * y, w * z
2662
+
2663
+ return CFrame.new(
2664
+ pos.X, pos.Y, pos.Z,
2665
+ 1 - 2 * (yy + zz), 2 * (xy - wz), 2 * (xz + wy),
2666
+ 2 * (xy + wz), 1 - 2 * (xx + zz), 2 * (yz - wx),
2667
+ 2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (xx + yy)
2668
+ )
2669
+ end
2670
+
2671
+ function Curves.slerp(cfA: CFrame, cfB: CFrame, alpha: number): CFrame
2672
+ if alpha <= 0 then return cfA end
2673
+ if alpha >= 1 then return cfB end
2674
+
2675
+ local posA, qA = cframeToQuat(cfA)
2676
+ local posB, qB = cframeToQuat(cfB)
2677
+
2678
+ local cosHalfTheta = qA.x * qB.x + qA.y * qB.y + qA.z * qB.z + qA.w * qB.w
2679
+ if cosHalfTheta < 0 then
2680
+ qB = { x = -qB.x, y = -qB.y, z = -qB.z, w = -qB.w }
2681
+ cosHalfTheta = -cosHalfTheta
2682
+ end
2683
+
2684
+ local qResult: Quat
2685
+ if math.abs(cosHalfTheta) >= 1 then
2686
+ qResult = qA
2687
+ else
2688
+ local halfTheta = math.acos(math.clamp(cosHalfTheta, -1, 1))
2689
+ local sinHalfTheta = math.sqrt(1 - cosHalfTheta * cosHalfTheta)
2690
+ if math.abs(sinHalfTheta) < 1e-4 then
2691
+ qResult = {
2692
+ x = qA.x * (1 - alpha) + qB.x * alpha,
2693
+ y = qA.y * (1 - alpha) + qB.y * alpha,
2694
+ z = qA.z * (1 - alpha) + qB.z * alpha,
2695
+ w = qA.w * (1 - alpha) + qB.w * alpha,
2696
+ }
2697
+ else
2698
+ local ratioA = math.sin((1 - alpha) * halfTheta) / sinHalfTheta
2699
+ local ratioB = math.sin(alpha * halfTheta) / sinHalfTheta
2700
+ qResult = {
2701
+ x = qA.x * ratioA + qB.x * ratioB,
2702
+ y = qA.y * ratioA + qB.y * ratioB,
2703
+ z = qA.z * ratioA + qB.z * ratioB,
2704
+ w = qA.w * ratioA + qB.w * ratioB,
2705
+ }
2706
+ end
2707
+ end
2708
+
2709
+ local interpPos = posA:Lerp(posB, alpha)
2710
+ return quatToCFrame(interpPos, qResult)
2711
+ end
2712
+
2713
+ return Curves
2714
+
2715
+ ]]></ProtectedString>
2716
+ </Properties>
2717
+ </Item>
2718
+ <Item class="ModuleScript" referent="RBX_nv_joint_math">
2719
+ <Properties>
2720
+ <BinaryString name="AttributesSerialize"></BinaryString>
2721
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
2722
+ <bool name="DefinesCapabilities">false</bool>
2723
+ <bool name="Disabled">false</bool>
2724
+ <Content name="LinkedSource"><null></null></Content>
2725
+ <string name="Name">nv_joint_math</string>
2726
+ <int64 name="SourceAssetId">-1</int64>
2727
+ <BinaryString name="Tags"></BinaryString>
2728
+ <ProtectedString name="Source"><![CDATA[--!strict
2729
+ -- ══════════════════════════════════════════════════════════════════════
2730
+ -- NVStudio Subsystem: nv_joint_math
2731
+ -- Deskripsi: Modul Matematika Kinematika Sambungan (Joints) & Transformasi
2732
+ -- Menyediakan kalkulasi C0/C1 presisi, pemindahan poros tanpa dislokasi,
2733
+ -- rotasi poros, penyelarasan koordinat, dan pencerminan simetri bilateral.
2734
+ -- ══════════════════════════════════════════════════════════════════════
2735
+
2736
+ local JointMath = {}
2737
+
2738
+ local EPSILON = 1e-6
2739
+
2740
+ -- ── 1. Hitung Offset Awal C0 & C1 ─────────────────────────────────────────────
2741
+ -- Menghubungkan Part0 dan Part1 tanpa mengubah posisi relatif fisik saat ini
2742
+ function JointMath.computeInitialOffsets(part0: BasePart, part1: BasePart): (CFrame, CFrame)
2743
+ local c0 = part0.CFrame:ToObjectSpace(part1.CFrame)
2744
+ local c1 = CFrame.identity
2745
+ return c0, c1
2746
+ end
2747
+
2748
+ -- ── 2. Geser Poros Sendi di Koordinat Dunia (World Delta) ─────────────────────
2749
+ -- Menggeser titik pivot tanpa mendislokasi posisi part anak di dunia 3D
2750
+ function JointMath.offsetPivot(joint: JointInstance, deltaWorld: Vector3): boolean
2751
+ local part0 = joint.Part0
2752
+ local part1 = joint.Part1
2753
+ if not part0 or not part1 then return false end
2754
+
2755
+ local origJointWorld = part0.CFrame * joint.C0
2756
+ local desiredWorld = origJointWorld + deltaWorld
2757
+
2758
+ local newC0 = part0.CFrame:ToObjectSpace(desiredWorld)
2759
+ local diff = joint.C0:ToObjectSpace(newC0)
2760
+ local newC1 = joint.C1 * diff
2761
+
2762
+ joint.C0 = newC0
2763
+ joint.C1 = newC1
2764
+ return true
2765
+ end
2766
+
2767
+ -- ── 3. Tetapkan Posisi Poros ke Titik Dunia Absolut (Set World Position) ──────
2768
+ -- Memindahkan titik tumpu sendi tepat ke koordinat 3D yang diinginkan
2769
+ function JointMath.setPivotWorld(joint: JointInstance, targetWorldPos: Vector3): boolean
2770
+ local part0 = joint.Part0
2771
+ local part1 = joint.Part1
2772
+ if not part0 or not part1 then return false end
2773
+
2774
+ local origJointWorld = part0.CFrame * joint.C0
2775
+ local targetWorldCF = CFrame.new(targetWorldPos) * origJointWorld.Rotation
2776
+
2777
+ local newC0 = part0.CFrame:ToObjectSpace(targetWorldCF)
2778
+ local diff = joint.C0:ToObjectSpace(newC0)
2779
+ local newC1 = joint.C1 * diff
2780
+
2781
+ joint.C0 = newC0
2782
+ joint.C1 = newC1
2783
+ return true
2784
+ end
2785
+
2786
+ -- ── 4. Rotasi Poros Sendi Tanpa Merusak Rig ───────────────────────────────────
2787
+ -- Memutar orientasi poros sendi sebesar sudut Euler radian
2788
+ function JointMath.rotatePivot(joint: JointInstance, rotEulerRad: Vector3): boolean
2789
+ local part0 = joint.Part0
2790
+ local part1 = joint.Part1
2791
+ if not part0 or not part1 then return false end
2792
+
2793
+ local rotCF = CFrame.Angles(rotEulerRad.X, rotEulerRad.Y, rotEulerRad.Z)
2794
+ joint.C0 = joint.C0 * rotCF
2795
+ joint.C1 = joint.C1 * rotCF
2796
+ return true
2797
+ end
2798
+
2799
+ -- ── 5. Reset Poros Sendi ke Pusat Part Anak ───────────────────────────────────
2800
+ -- Mengembalikan poros sendi ke origin geometris Part1
2801
+ function JointMath.resetPivot(joint: JointInstance): boolean
2802
+ local part0 = joint.Part0
2803
+ local part1 = joint.Part1
2804
+ if not part0 or not part1 then return false end
2805
+
2806
+ joint.C0 = part0.CFrame:ToObjectSpace(part1.CFrame)
2807
+ joint.C1 = CFrame.identity
2808
+ return true
2809
+ end
2810
+
2811
+ -- ── 6. Selaraskan Orientasi Poros ke Part0 atau Part1 ─────────────────────────
2812
+ -- Menjaga posisi poros di dunia tetap sama, tetapi rotasinya mengikuti part referensi
2813
+ function JointMath.alignJoint(joint: JointInstance, alignTo: "Part0" | "Part1"): boolean
2814
+ local part0 = joint.Part0
2815
+ local part1 = joint.Part1
2816
+ if not part0 or not part1 then return false end
2817
+
2818
+ local pivotWorldPos = (part0.CFrame * joint.C0).Position
2819
+ local refCF = alignTo == "Part0" and part0.CFrame or part1.CFrame
2820
+ local targetWorldCF = CFrame.new(pivotWorldPos) * refCF.Rotation
2821
+
2822
+ local newC0 = part0.CFrame:ToObjectSpace(targetWorldCF)
2823
+ local diff = joint.C0:ToObjectSpace(newC0)
2824
+ local newC1 = joint.C1 * diff
2825
+
2826
+ joint.C0 = newC0
2827
+ joint.C1 = newC1
2828
+ return true
2829
+ end
2830
+
2831
+ -- ── 7. Refleksi CFrame Sumbu X Simetris (Bilateral Mirroring) ──────────────────
2832
+ -- Mencerminkan CFrame melewati bidang sagittal (sumbu X)
2833
+ function JointMath.mirrorCFrame(cf: CFrame): CFrame
2834
+ local x, y, z,
2835
+ r00, r01, r02,
2836
+ r10, r11, r12,
2837
+ r20, r21, r22 = cf:GetComponents()
2838
+
2839
+ -- Refleksi koordinat posisi dan rotasi M_x * R * M_x
2840
+ return CFrame.new(
2841
+ -x, y, z,
2842
+ r00, -r01, -r02,
2843
+ -r10, r11, r12,
2844
+ -r20, r21, r22
2845
+ )
2846
+ end
2847
+
2848
+ -- ── 8. Cerminkan Sendi Bilateral Kiri <-> Kanan ────────────────────────────────
2849
+ -- Menerapkan C0 dan C1 yang telah dicerminkan dari sumber ke sendi target
2850
+ function JointMath.mirrorJoint(sourceJoint: JointInstance, targetJoint: JointInstance): boolean
2851
+ if not sourceJoint or not targetJoint then return false end
2852
+ targetJoint.C0 = JointMath.mirrorCFrame(sourceJoint.C0)
2853
+ targetJoint.C1 = JointMath.mirrorCFrame(sourceJoint.C1)
2854
+ return true
2855
+ end
2856
+
2857
+ -- ── 9. Dapatkan Posisi Poros di Dunia ─────────────────────────────────────────
2858
+ function JointMath.getJointWorldPosition(joint: JointInstance): Vector3?
2859
+ local part0 = joint.Part0
2860
+ if not part0 then return nil end
2861
+ return (part0.CFrame * joint.C0).Position
2862
+ end
2863
+
2864
+ return JointMath
2865
+
2866
+ ]]></ProtectedString>
2867
+ </Properties>
2868
+ </Item>
2869
+ <Item class="ModuleScript" referent="RBX_nv_kinematics">
2870
+ <Properties>
2871
+ <BinaryString name="AttributesSerialize"></BinaryString>
2872
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
2873
+ <bool name="DefinesCapabilities">false</bool>
2874
+ <bool name="Disabled">false</bool>
2875
+ <Content name="LinkedSource"><null></null></Content>
2876
+ <string name="Name">nv_kinematics</string>
2877
+ <int64 name="SourceAssetId">-1</int64>
2878
+ <BinaryString name="Tags"></BinaryString>
2879
+ <ProtectedString name="Source"><![CDATA[--!strict
2880
+ -- ══════════════════════════════════════════════════════════════════════
2881
+ -- NVStudio Subsystem: nv_kinematics
2882
+ -- Deskripsi: Mesin Kinematika Analitis 2-Link LimbIK & Abstraksi R6/R15
2883
+ -- Menyelesaikan posisi siku/lutut menggunakan Hukum Cosinus O(1) tertutup,
2884
+ -- ekstensi SoftIK anti-hentakan kaku, dan stabilisasi rotasi leher/kaki.
2885
+ -- ══════════════════════════════════════════════════════════════════════
2886
+
2887
+ local Kinematics = {}
2888
+ local EPSILON = 1e-6
2889
+
2890
+ -- ── 1. SoftIK Algorithm ───────────────────────────────────────────────────────
2891
+ local function softDistance(dist: number, reach: number, softPercent: number?): number
2892
+ if dist <= 0 or reach <= 0 then return 0 end
2893
+ local pct = math.clamp(softPercent or 90, 0, 100) / 100
2894
+ local onset = reach * pct
2895
+ if dist <= onset then
2896
+ return dist
2897
+ end
2898
+ local zone = reach - onset
2899
+ if zone <= 0 then
2900
+ return math.min(dist, reach)
2901
+ end
2902
+ local x = (dist - onset) / zone
2903
+ local eased = x < 1e-4 and (x * (1 - x * 0.5)) or (1 - math.exp(-x))
2904
+ return math.min(dist, reach, onset + zone * eased)
2905
+ end
2906
+
2907
+ -- ── 2. Rotation Between Two Vectors ───────────────────────────────────────────
2908
+ function Kinematics.rotationBetween(from: Vector3, to: Vector3): CFrame
2909
+ if from.Magnitude < EPSILON or to.Magnitude < EPSILON then
2910
+ return CFrame.identity
2911
+ end
2912
+ from = from.Unit
2913
+ to = to.Unit
2914
+ local dot = math.clamp(from:Dot(to), -1, 1)
2915
+ local cross = from:Cross(to)
2916
+ if cross.Magnitude < EPSILON then
2917
+ if dot > 0 then
2918
+ return CFrame.identity
2919
+ end
2920
+ local basis = math.abs(from.X) < 0.8 and Vector3.xAxis or Vector3.yAxis
2921
+ return CFrame.fromAxisAngle(from:Cross(basis).Unit, math.pi)
2922
+ end
2923
+ return CFrame.fromAxisAngle(cross.Unit, math.atan2(cross.Magnitude, dot))
2924
+ end
2925
+
2926
+ -- ── 3. Analytical 2-Bone Limb Solver (Law of Cosines) ─────────────────────────
2927
+ function Kinematics.solveTwoLink(
2928
+ root: Vector3,
2929
+ middle: Vector3,
2930
+ tip: Vector3,
2931
+ goal: Vector3,
2932
+ pole: Vector3?,
2933
+ softPercent: number?
2934
+ ): (Vector3, Vector3)
2935
+ local upperLen = (middle - root).Magnitude
2936
+ local lowerLen = (tip - middle).Magnitude
2937
+ if upperLen < EPSILON or lowerLen < EPSILON then
2938
+ return middle, tip
2939
+ end
2940
+
2941
+ local delta = goal - root
2942
+ local rawDist = delta.Magnitude
2943
+ local dir = rawDist > EPSILON and delta.Unit or (tip - root).Unit
2944
+ local reach = upperLen + lowerLen
2945
+
2946
+ -- Soften distance
2947
+ local softened = softDistance(rawDist, reach, softPercent or 90)
2948
+ local dist = math.clamp(softened, math.max(math.abs(upperLen - lowerLen), EPSILON), reach - 1e-4)
2949
+
2950
+ -- Bend direction towards pole
2951
+ local polePos = pole or (middle + Vector3.new(0, 0, -1))
2952
+ local bend = polePos - root
2953
+ bend = bend - dir * bend:Dot(dir)
2954
+ if bend.Magnitude < EPSILON then
2955
+ local basis = math.abs(dir.X) < 0.8 and Vector3.xAxis or Vector3.yAxis
2956
+ bend = basis - dir * basis:Dot(dir)
2957
+ end
2958
+ bend = bend.Unit
2959
+
2960
+ -- Law of Cosines: Upper arm projection along direction vector
2961
+ local along = (upperLen * upperLen - lowerLen * lowerLen + dist * dist) / (2 * dist)
2962
+ local heightSq = upperLen * upperLen - along * along
2963
+ local height = math.sqrt(math.max(0, heightSq))
2964
+
2965
+ local solvedMiddle = root + dir * along + bend * height
2966
+ local solvedTip = root + dir * dist
2967
+ return solvedMiddle, solvedTip
2968
+ end
2969
+
2970
+ -- ── 4. Rig Discovery & Abstraction (R6 & R15) ──────────────────────────────────
2971
+ export type RigInfo = {
2972
+ model: Model,
2973
+ isR15: boolean,
2974
+ rootPart: BasePart,
2975
+ motors: { [string]: Motor6D },
2976
+ parts: { [string]: BasePart }
2977
+ }
2978
+
2979
+ function Kinematics.inspectRig(model: Model): RigInfo?
2980
+ if not model or not model:IsA("Model") then return nil end
2981
+ local rootPart = model:FindFirstChild("HumanoidRootPart") :: BasePart?
2982
+ if not rootPart then
2983
+ rootPart = model.PrimaryPart or model:FindFirstChildWhichIsA("BasePart")
2984
+ end
2985
+ if not rootPart then return nil end
2986
+
2987
+ local humanoid = model:FindFirstChildOfClass("Humanoid")
2988
+ local isR15 = humanoid and (humanoid.RigType == Enum.HumanoidRigType.R15) or false
2989
+ if not humanoid then
2990
+ isR15 = model:FindFirstChild("UpperTorso") ~= nil
2991
+ end
2992
+
2993
+ local motors: { [string]: Motor6D } = {}
2994
+ local parts: { [string]: BasePart } = {}
2995
+
2996
+ for _, desc in ipairs(model:GetDescendants()) do
2997
+ if desc:IsA("Motor6D") then
2998
+ motors[desc.Name] = desc
2999
+ elseif desc:IsA("BasePart") then
3000
+ parts[desc.Name] = desc
3001
+ end
3002
+ end
3003
+
3004
+ return {
3005
+ model = model,
3006
+ isR15 = isR15,
3007
+ rootPart = rootPart,
3008
+ motors = motors,
3009
+ parts = parts
3010
+ }
3011
+ end
3012
+
3013
+ -- ── 5. Solve Limb IK on R15 / R6 Rig ──────────────────────────────────────────
3014
+ function Kinematics.solveLimb(
3015
+ rigInfo: RigInfo,
3016
+ limbName: string, -- "LeftArm" | "RightArm" | "LeftLeg" | "RightLeg"
3017
+ targetWorldPos: Vector3,
3018
+ poleWorldPos: Vector3?,
3019
+ applyToRig: boolean?
3020
+ ): { [string]: CFrame }?
3021
+ local m = rigInfo.motors
3022
+ local p = rigInfo.parts
3023
+
3024
+ if rigInfo.isR15 then
3025
+ local upperMotorName, lowerMotorName, endMotorName
3026
+ local upperPartName, lowerPartName, endPartName
3027
+ local defaultPoleOffset = Vector3.new(0, 0, -2)
3028
+
3029
+ if limbName == "LeftArm" then
3030
+ upperMotorName, lowerMotorName, endMotorName = "LeftShoulder", "LeftElbow", "LeftWrist"
3031
+ upperPartName, lowerPartName, endPartName = "LeftUpperArm", "LeftLowerArm", "LeftHand"
3032
+ defaultPoleOffset = Vector3.new(-1, 0, -2)
3033
+ elseif limbName == "RightArm" then
3034
+ upperMotorName, lowerMotorName, endMotorName = "RightShoulder", "RightElbow", "RightWrist"
3035
+ upperPartName, lowerPartName, endPartName = "RightUpperArm", "RightLowerArm", "RightHand"
3036
+ defaultPoleOffset = Vector3.new(1, 0, -2)
3037
+ elseif limbName == "LeftLeg" then
3038
+ upperMotorName, lowerMotorName, endMotorName = "LeftHip", "LeftKnee", "LeftAnkle"
3039
+ upperPartName, lowerPartName, endPartName = "LeftUpperLeg", "LeftLowerLeg", "LeftFoot"
3040
+ defaultPoleOffset = Vector3.new(0, 0, 2)
3041
+ elseif limbName == "RightLeg" then
3042
+ upperMotorName, lowerMotorName, endMotorName = "RightHip", "RightKnee", "RightAnkle"
3043
+ upperPartName, lowerPartName, endPartName = "RightUpperLeg", "RightLowerLeg", "RightFoot"
3044
+ defaultPoleOffset = Vector3.new(0, 0, 2)
3045
+ else
3046
+ return nil
3047
+ end
3048
+
3049
+ local upperMotor = m[upperMotorName]
3050
+ local lowerMotor = m[lowerMotorName]
3051
+ local upperPart = p[upperPartName]
3052
+ local lowerPart = p[lowerPartName]
3053
+ local endPart = p[endPartName]
3054
+
3055
+ if not upperMotor or not lowerMotor or not upperPart or not lowerPart or not endPart then
3056
+ return nil
3057
+ end
3058
+
3059
+ local rootPos = upperPart.Position
3060
+ local midPos = lowerPart.Position
3061
+ local tipPos = endPart.Position
3062
+ local pole = poleWorldPos or (midPos + defaultPoleOffset)
3063
+
3064
+ local solvedMid, solvedTip = Kinematics.solveTwoLink(rootPos, midPos, tipPos, targetWorldPos, pole, 90)
3065
+
3066
+ -- Hitung C0 baru untuk upper dan lower
3067
+ local upperParentCF = upperMotor.Part0.CFrame
3068
+ local upperGoalWorldCF = CFrame.lookAt(rootPos, solvedMid, pole - rootPos)
3069
+ local upperC0 = upperParentCF:Inverse() * upperGoalWorldCF * upperMotor.C1
3070
+
3071
+ local lowerParentCF = solvedMid ~= rootPos and CFrame.lookAt(rootPos, solvedMid) or upperPart.CFrame
3072
+ local lowerGoalWorldCF = CFrame.lookAt(solvedMid, solvedTip, pole - solvedMid)
3073
+ local lowerC0 = lowerParentCF:Inverse() * lowerGoalWorldCF * lowerMotor.C1
3074
+
3075
+ if applyToRig then
3076
+ upperMotor.C0 = upperC0
3077
+ lowerMotor.C0 = lowerC0
3078
+ end
3079
+
3080
+ return {
3081
+ [upperMotorName] = upperC0,
3082
+ [lowerMotorName] = lowerC0
3083
+ }
3084
+ else
3085
+ -- Mode R6: 1 bone per limb, arahkan ke targetWorldPos
3086
+ local motorName, partName
3087
+ if limbName == "LeftArm" then motorName, partName = "Left Shoulder", "Left Arm"
3088
+ elseif limbName == "RightArm" then motorName, partName = "Right Shoulder", "Right Arm"
3089
+ elseif limbName == "LeftLeg" then motorName, partName = "Left Hip", "Left Leg"
3090
+ elseif limbName == "RightLeg" then motorName, partName = "Right Hip", "Right Leg"
3091
+ else return nil end
3092
+
3093
+ local motor = m[motorName]
3094
+ local part = p[partName]
3095
+ if not motor or not part or not motor.Part0 then return nil end
3096
+
3097
+ local rootPos = part.Position
3098
+ local goalCF = CFrame.lookAt(rootPos, targetWorldPos)
3099
+ local c0 = motor.Part0.CFrame:Inverse() * goalCF * motor.C1
3100
+
3101
+ if applyToRig then
3102
+ motor.C0 = c0
3103
+ end
3104
+ return { [motorName] = c0 }
3105
+ end
3106
+ end
3107
+
3108
+ -- ── 6. Head Look-At Stabilizer ────────────────────────────────────────────────
3109
+ function Kinematics.solveLookAt(rigInfo: RigInfo, targetWorldPos: Vector3, applyToRig: boolean?): CFrame?
3110
+ local neckMotor = rigInfo.motors["Neck"]
3111
+ local headPart = rigInfo.parts["Head"]
3112
+ if not neckMotor or not headPart or not neckMotor.Part0 then return nil end
3113
+
3114
+ local headPos = headPart.Position
3115
+ local lookDir = (targetWorldPos - headPos).Unit
3116
+ local targetWorldCF = CFrame.lookAt(headPos, headPos + lookDir)
3117
+ local neckC0 = neckMotor.Part0.CFrame:Inverse() * targetWorldCF * neckMotor.C1
3118
+
3119
+ if applyToRig then
3120
+ neckMotor.C0 = neckC0
3121
+ end
3122
+ return neckC0
3123
+ end
3124
+
3125
+ return Kinematics
3126
+
3127
+ ]]></ProtectedString>
3128
+ </Properties>
3129
+ </Item>
3130
+ <Item class="ModuleScript" referent="RBX_nv_motion_generator">
3131
+ <Properties>
3132
+ <BinaryString name="AttributesSerialize"></BinaryString>
3133
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
3134
+ <bool name="DefinesCapabilities">false</bool>
3135
+ <bool name="Disabled">false</bool>
3136
+ <Content name="LinkedSource"><null></null></Content>
3137
+ <string name="Name">nv_motion_generator</string>
3138
+ <int64 name="SourceAssetId">-1</int64>
3139
+ <BinaryString name="Tags"></BinaryString>
3140
+ <ProtectedString name="Source"><![CDATA[--!strict
3141
+ -- ══════════════════════════════════════════════════════════════════════
3142
+ -- NVStudio Subsystem: nv_motion_generator
3143
+ -- Deskripsi: Generator Gerakan Karakter Alami (Human-like Motion Synthesis)
3144
+ -- Menghasilkan pose kunci realistis (Anticipation, Follow-through, Easing)
3145
+ -- untuk berjalan, berlari, tebasan pedang, tinju, idle, dan lompat.
3146
+ -- ══════════════════════════════════════════════════════════════════════
3147
+
3148
+ local curvesMod = script.Parent:WaitForChild("nv_curves")
3149
+ local Curves = require(curvesMod)
3150
+ local kinematicsMod = script.Parent:WaitForChild("nv_kinematics")
3151
+ local Kinematics = require(kinematicsMod)
3152
+
3153
+ local MotionGenerator = {}
3154
+
3155
+ export type KeyframeData = {
3156
+ time: number, -- 0.0 to duration
3157
+ poses: { [string]: CFrame } -- motorName -> C0 CFrame
3158
+ }
3159
+
3160
+ -- ── Helper Angles ─────────────────────────────────────────────────────────────
3161
+ local function anglesDeg(x: number, y: number, z: number): CFrame
3162
+ return CFrame.Angles(math.rad(x), math.rad(y), math.rad(z))
3163
+ end
3164
+
3165
+ -- ── 1. Generator: Walk Cycle ──────────────────────────────────────────────────
3166
+ function MotionGenerator.generateWalkCycle(rigInfo: Kinematics.RigInfo, options: { [string]: any }?): { KeyframeData }
3167
+ local opts = options or {}
3168
+ local duration = tonumber(opts.duration) or 1.0
3169
+ local intensity = tonumber(opts.intensity) or 1.0
3170
+ local isR15 = rigInfo.isR15
3171
+
3172
+ -- Base offsets per motor
3173
+ local m = rigInfo.motors
3174
+ local defaultC0 = {}
3175
+ for name, motor in pairs(m) do
3176
+ defaultC0[name] = motor.C0
3177
+ end
3178
+
3179
+ local legAngle = 26 * intensity
3180
+ local armAngle = 22 * intensity
3181
+ local torsoBob = 0.12 * intensity
3182
+ local torsoTwist = 6 * intensity
3183
+
3184
+ local kfTimes = { 0.0, 0.25, 0.50, 0.75, 1.0 }
3185
+ local keyframes: { KeyframeData } = {}
3186
+
3187
+ for _, tFrac in ipairs(kfTimes) do
3188
+ local tSec = tFrac * duration
3189
+ local poses: { [string]: CFrame } = {}
3190
+
3191
+ if isR15 then
3192
+ local legPhase = math.sin(tFrac * math.pi * 2)
3193
+ local armPhase = -legPhase
3194
+
3195
+ -- Torso movement (vertical bounce on each step)
3196
+ local bobY = math.abs(math.cos(tFrac * math.pi * 2)) * torsoBob
3197
+ local rootC0 = defaultC0["Root"] or CFrame.identity
3198
+ poses["Root"] = rootC0 * CFrame.new(0, -bobY, 0) * anglesDeg(4, legPhase * torsoTwist, 0)
3199
+
3200
+ -- Left Leg & Right Leg
3201
+ local lHip = defaultC0["LeftHip"] or CFrame.identity
3202
+ local rHip = defaultC0["RightHip"] or CFrame.identity
3203
+ local lKnee = defaultC0["LeftKnee"] or CFrame.identity
3204
+ local rKnee = defaultC0["RightKnee"] or CFrame.identity
3205
+
3206
+ poses["LeftHip"] = lHip * anglesDeg(legPhase * legAngle, 0, 0)
3207
+ poses["RightHip"] = rHip * anglesDeg(-legPhase * legAngle, 0, 0)
3208
+
3209
+ -- Knee bends when leg swings back
3210
+ local lKneeBend = math.clamp(-legPhase * legAngle * 1.5, 0, 45)
3211
+ local rKneeBend = math.clamp(legPhase * legAngle * 1.5, 0, 45)
3212
+ poses["LeftKnee"] = lKnee * anglesDeg(lKneeBend, 0, 0)
3213
+ poses["RightKnee"] = rKnee * anglesDeg(rKneeBend, 0, 0)
3214
+
3215
+ -- Left Arm & Right Arm (opposite to legs)
3216
+ local lShoulder = defaultC0["LeftShoulder"] or CFrame.identity
3217
+ local rShoulder = defaultC0["RightShoulder"] or CFrame.identity
3218
+ poses["LeftShoulder"] = lShoulder * anglesDeg(armPhase * armAngle, 0, -4)
3219
+ poses["RightShoulder"] = rShoulder * anglesDeg(-armPhase * armAngle, 0, 4)
3220
+ else
3221
+ -- R6 Rig
3222
+ local legPhase = math.sin(tFrac * math.pi * 2)
3223
+ local armPhase = -legPhase
3224
+
3225
+ local lShoulder = defaultC0["Left Shoulder"] or CFrame.identity
3226
+ local rShoulder = defaultC0["Right Shoulder"] or CFrame.identity
3227
+ local lHip = defaultC0["Left Hip"] or CFrame.identity
3228
+ local rHip = defaultC0["Right Hip"] or CFrame.identity
3229
+
3230
+ poses["Left Shoulder"] = lShoulder * anglesDeg(armPhase * armAngle, 0, 0)
3231
+ poses["Right Shoulder"] = rShoulder * anglesDeg(-armPhase * armAngle, 0, 0)
3232
+ poses["Left Hip"] = lHip * anglesDeg(legPhase * legAngle, 0, 0)
3233
+ poses["Right Hip"] = rHip * anglesDeg(-legPhase * legAngle, 0, 0)
3234
+ end
3235
+
3236
+ table.insert(keyframes, { time = tSec, poses = poses })
3237
+ end
3238
+
3239
+ return keyframes
3240
+ end
3241
+
3242
+ -- ── 2. Generator: Melee Slash (Combat Attack) ─────────────────────────────────
3243
+ function MotionGenerator.generateMeleeSlash(rigInfo: Kinematics.RigInfo, options: { [string]: any }?): { KeyframeData }
3244
+ local opts = options or {}
3245
+ local duration = tonumber(opts.duration) or 0.8
3246
+ local isR15 = rigInfo.isR15
3247
+
3248
+ local defaultC0 = {}
3249
+ for name, motor in pairs(rigInfo.motors) do
3250
+ defaultC0[name] = motor.C0
3251
+ end
3252
+
3253
+ -- Keyframes: 0 (Ready), 0.25 (Wind-up Anticipation), 0.50 (Slash Snap), 0.75 (Follow-Through), 1.0 (Recover)
3254
+ local points = {
3255
+ {
3256
+ t = 0.0,
3257
+ armRot = anglesDeg(10, 0, 5),
3258
+ torsoRot = anglesDeg(0, 0, 0),
3259
+ lArmRot = anglesDeg(5, 0, -5)
3260
+ },
3261
+ {
3262
+ t = 0.25, -- Wind-up
3263
+ armRot = anglesDeg(-110, -35, 40),
3264
+ torsoRot = anglesDeg(-5, -25, 0),
3265
+ lArmRot = anglesDeg(20, 15, -15)
3266
+ },
3267
+ {
3268
+ t = 0.50, -- Slash Strike
3269
+ armRot = anglesDeg(65, 30, -25),
3270
+ torsoRot = anglesDeg(10, 35, 0),
3271
+ lArmRot = anglesDeg(-25, -10, -20)
3272
+ },
3273
+ {
3274
+ t = 0.75, -- Follow-through overshoot
3275
+ armRot = anglesDeg(75, 40, -30),
3276
+ torsoRot = anglesDeg(8, 40, 0),
3277
+ lArmRot = anglesDeg(-15, -5, -15)
3278
+ },
3279
+ {
3280
+ t = 1.0, -- Return
3281
+ armRot = anglesDeg(10, 0, 5),
3282
+ torsoRot = anglesDeg(0, 0, 0),
3283
+ lArmRot = anglesDeg(5, 0, -5)
3284
+ }
3285
+ }
3286
+
3287
+ local keyframes: { KeyframeData } = {}
3288
+ for _, pt in ipairs(points) do
3289
+ local poses: { [string]: CFrame } = {}
3290
+ if isR15 then
3291
+ poses["RightShoulder"] = (defaultC0["RightShoulder"] or CFrame.identity) * pt.armRot
3292
+ poses["LeftShoulder"] = (defaultC0["LeftShoulder"] or CFrame.identity) * pt.lArmRot
3293
+ poses["Waist"] = (defaultC0["Waist"] or CFrame.identity) * pt.torsoRot
3294
+ else
3295
+ poses["Right Shoulder"] = (defaultC0["Right Shoulder"] or CFrame.identity) * pt.armRot
3296
+ poses["Left Shoulder"] = (defaultC0["Left Shoulder"] or CFrame.identity) * pt.lArmRot
3297
+ poses["RootJoint"] = (defaultC0["RootJoint"] or CFrame.identity) * pt.torsoRot
3298
+ end
3299
+ table.insert(keyframes, { time = pt.t * duration, poses = poses })
3300
+ end
3301
+
3302
+ return keyframes
3303
+ end
3304
+
3305
+ -- ── 3. Generator: Punch Combo ─────────────────────────────────────────────────
3306
+ function MotionGenerator.generatePunch(rigInfo: Kinematics.RigInfo, options: { [string]: any }?): { KeyframeData }
3307
+ local opts = options or {}
3308
+ local duration = tonumber(opts.duration) or 0.6
3309
+ local isR15 = rigInfo.isR15
3310
+
3311
+ local defaultC0 = {}
3312
+ for name, motor in pairs(rigInfo.motors) do
3313
+ defaultC0[name] = motor.C0
3314
+ end
3315
+
3316
+ local points = {
3317
+ { t = 0.0, rArm = anglesDeg(0, 0, 0), torso = anglesDeg(0, 0, 0) },
3318
+ { t = 0.2, rArm = anglesDeg(-20, -10, 15), torso = anglesDeg(0, -15, 0) }, -- Coil back
3319
+ { t = 0.45, rArm = anglesDeg(90, 10, -5), torso = anglesDeg(8, 25, 0) }, -- Thrust forward
3320
+ { t = 0.7, rArm = anglesDeg(70, 5, -2), torso = anglesDeg(4, 15, 0) }, -- Recoil
3321
+ { t = 1.0, rArm = anglesDeg(0, 0, 0), torso = anglesDeg(0, 0, 0) }
3322
+ }
3323
+
3324
+ local keyframes: { KeyframeData } = {}
3325
+ for _, pt in ipairs(points) do
3326
+ local poses: { [string]: CFrame } = {}
3327
+ if isR15 then
3328
+ poses["RightShoulder"] = (defaultC0["RightShoulder"] or CFrame.identity) * pt.rArm
3329
+ poses["Waist"] = (defaultC0["Waist"] or CFrame.identity) * pt.torso
3330
+ else
3331
+ poses["Right Shoulder"] = (defaultC0["Right Shoulder"] or CFrame.identity) * pt.rArm
3332
+ poses["RootJoint"] = (defaultC0["RootJoint"] or CFrame.identity) * pt.torso
3333
+ end
3334
+ table.insert(keyframes, { time = pt.t * duration, poses = poses })
3335
+ end
3336
+
3337
+ return keyframes
3338
+ end
3339
+
3340
+ -- ── 4. Generator: Idle Breathing ──────────────────────────────────────────────
3341
+ function MotionGenerator.generateIdle(rigInfo: Kinematics.RigInfo, options: { [string]: any }?): { KeyframeData }
3342
+ local opts = options or {}
3343
+ local duration = tonumber(opts.duration) or 2.5
3344
+ local isR15 = rigInfo.isR15
3345
+
3346
+ local defaultC0 = {}
3347
+ for name, motor in pairs(rigInfo.motors) do
3348
+ defaultC0[name] = motor.C0
3349
+ end
3350
+
3351
+ local keyframes: { KeyframeData } = {}
3352
+ local steps = 8
3353
+ for i = 0, steps do
3354
+ local frac = i / steps
3355
+ local timeSec = frac * duration
3356
+ local breathCycle = math.sin(frac * math.pi * 2)
3357
+
3358
+ local poses: { [string]: CFrame } = {}
3359
+ if isR15 then
3360
+ poses["Root"] = (defaultC0["Root"] or CFrame.identity) * CFrame.new(0, breathCycle * 0.04, 0)
3361
+ poses["Waist"] = (defaultC0["Waist"] or CFrame.identity) * anglesDeg(breathCycle * 1.5, 0, 0)
3362
+ poses["Neck"] = (defaultC0["Neck"] or CFrame.identity) * anglesDeg(-breathCycle * 1.0, 0, 0)
3363
+ else
3364
+ poses["RootJoint"] = (defaultC0["RootJoint"] or CFrame.identity) * CFrame.new(0, 0, breathCycle * 0.04)
3365
+ poses["Neck"] = (defaultC0["Neck"] or CFrame.identity) * anglesDeg(-breathCycle * 1.2, 0, 0)
3366
+ end
3367
+
3368
+ table.insert(keyframes, { time = timeSec, poses = poses })
3369
+ end
3370
+
3371
+ return keyframes
3372
+ end
3373
+
3374
+ -- ── 5. Dense Sampler: Interpolasi Keyframes Menggunakan SLERP & Easing ────────
3375
+ function MotionGenerator.sampleSequence(
3376
+ keyframes: { KeyframeData },
3377
+ fps: number,
3378
+ easing: string?
3379
+ ): { [number]: { [string]: CFrame } }
3380
+ if #keyframes == 0 then return {} end
3381
+ table.sort(keyframes, function(a, b) return a.time < b.time end)
3382
+
3383
+ local totalDuration = keyframes[#keyframes].time
3384
+ local totalFrames = math.max(1, math.floor(totalDuration * fps))
3385
+ local sampled: { [number]: { [string]: CFrame } } = {}
3386
+
3387
+ for f = 0, totalFrames do
3388
+ local curTime = (f / totalFrames) * totalDuration
3389
+
3390
+ -- Cari segment keyframe [prev, next]
3391
+ local kA = keyframes[1]
3392
+ local kB = keyframes[#keyframes]
3393
+ for i = 1, #keyframes - 1 do
3394
+ if curTime >= keyframes[i].time and curTime <= keyframes[i + 1].time then
3395
+ kA = keyframes[i]
3396
+ kB = keyframes[i + 1]
3397
+ break
3398
+ end
3399
+ end
3400
+
3401
+ local segDuration = kB.time - kA.time
3402
+ local rawAlpha = segDuration > 1e-5 and ((curTime - kA.time) / segDuration) or 0
3403
+ local curvedAlpha = Curves.evaluate(easing or "QuadInOut", rawAlpha)
3404
+
3405
+ local framePoses: { [string]: CFrame } = {}
3406
+ for motorName, cfA in pairs(kA.poses) do
3407
+ local cfB = kB.poses[motorName] or cfA
3408
+ framePoses[motorName] = Curves.slerp(cfA, cfB, curvedAlpha)
3409
+ end
3410
+
3411
+ sampled[f] = framePoses
3412
+ end
3413
+
3414
+ return sampled
3415
+ end
3416
+
3417
+ return MotionGenerator
3418
+
3419
+ ]]></ProtectedString>
3420
+ </Properties>
3421
+ </Item>
3422
+ <Item class="ModuleScript" referent="RBX_nv_rig_dispatcher">
3423
+ <Properties>
3424
+ <BinaryString name="AttributesSerialize"></BinaryString>
3425
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
3426
+ <bool name="DefinesCapabilities">false</bool>
3427
+ <bool name="Disabled">false</bool>
3428
+ <Content name="LinkedSource"><null></null></Content>
3429
+ <string name="Name">nv_rig_dispatcher</string>
3430
+ <int64 name="SourceAssetId">-1</int64>
3431
+ <BinaryString name="Tags"></BinaryString>
3432
+ <ProtectedString name="Source"><![CDATA[--!strict
3433
+ -- ══════════════════════════════════════════════════════════════════════
3434
+ -- NVStudio Subsystem: nv_rig_dispatcher
3435
+ -- Deskripsi: Headless Modular Command Dispatcher untuk NV-Rig Engine
3436
+ -- Mengelola routing perintah rigging dari AI Agent LLM, eksekusi
3437
+ -- deterministik, inspeksi topologi rig, dan diagnostik otomatis.
3438
+ -- ══════════════════════════════════════════════════════════════════════
3439
+
3440
+ local jointMathMod = script.Parent:WaitForChild("nv_joint_math")
3441
+ local JointMath = require(jointMathMod)
3442
+ local autoRiggerMod = script.Parent:WaitForChild("nv_auto_rigger")
3443
+ local AutoRigger = require(autoRiggerMod)
3444
+
3445
+ local Dispatcher = {}
3446
+
3447
+ -- ── 1. Inspeksi & Diagnostik Rig Mendalam (Deep Topological Inspection) ────────
3448
+ function Dispatcher.inspectRig(targetInstance: Instance): { [string]: any }
3449
+ if not targetInstance then
3450
+ return { status = "error", error = "Target instance tidak ditemukan." }
3451
+ end
3452
+
3453
+ local model = targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model")
3454
+ if not model then
3455
+ model = targetInstance:IsA("Folder") and targetInstance or targetInstance:IsA("BasePart") and targetInstance.Parent :: any
3456
+ end
3457
+ if not model then
3458
+ return { status = "error", error = "Target harus berada di dalam Model atau Folder." }
3459
+ end
3460
+
3461
+ local parts: { [string]: { anchored: boolean, size: { number }, mass: number, className: string } } = {}
3462
+ local joints: { any } = {}
3463
+ local parentToChildren: { [string]: { string } } = {}
3464
+ local childToParent: { [string]: string } = {}
3465
+ local rootCandidates: { string } = {}
3466
+ local anchoredCount = 0
3467
+ local totalParts = 0
3468
+
3469
+ for _, desc in ipairs(model:GetDescendants()) do
3470
+ if desc:IsA("BasePart") then
3471
+ totalParts += 1
3472
+ if desc.Anchored then anchoredCount += 1 end
3473
+ parts[desc.Name] = {
3474
+ anchored = desc.Anchored,
3475
+ size = { desc.Size.X, desc.Size.Y, desc.Size.Z },
3476
+ mass = desc:GetMass(),
3477
+ className = desc.ClassName
3478
+ }
3479
+ elseif desc:IsA("Motor6D") or desc:IsA("Weld") then
3480
+ local p0Name = desc.Part0 and desc.Part0.Name or "nil"
3481
+ local p1Name = desc.Part1 and desc.Part1.Name or "nil"
3482
+ local pivotWorld = JointMath.getJointWorldPosition(desc)
3483
+
3484
+ table.insert(joints, {
3485
+ name = desc.Name,
3486
+ type = desc.ClassName,
3487
+ part0 = p0Name,
3488
+ part1 = p1Name,
3489
+ parent = desc.Parent and desc.Parent.Name or "nil",
3490
+ pivotWorld = pivotWorld and { pivotWorld.X, pivotWorld.Y, pivotWorld.Z } or nil,
3491
+ c0 = { desc.C0:GetComponents() },
3492
+ c1 = { desc.C1:GetComponents() }
3493
+ })
3494
+
3495
+ if p0Name ~= "nil" and p1Name ~= "nil" then
3496
+ if not parentToChildren[p0Name] then parentToChildren[p0Name] = {} end
3497
+ table.insert(parentToChildren[p0Name], p1Name)
3498
+ childToParent[p1Name] = p0Name
3499
+ end
3500
+ end
3501
+ end
3502
+
3503
+ -- Deteksi Root Part (Part yang tidak memiliki Part0 di atasnya)
3504
+ for partName, _ in pairs(parts) do
3505
+ if not childToParent[partName] then
3506
+ table.insert(rootCandidates, partName)
3507
+ end
3508
+ end
3509
+
3510
+ -- Deteksi Loop / Circular Dependency dalam hierarki
3511
+ local hasCycle = false
3512
+ local cyclePath = ""
3513
+ local visited: { [string]: boolean } = {}
3514
+ local recStack: { [string]: boolean } = {}
3515
+
3516
+ local function checkCycle(node: string, path: string): boolean
3517
+ visited[node] = true
3518
+ recStack[node] = true
3519
+ local children = parentToChildren[node] or {}
3520
+ for _, child in ipairs(children) do
3521
+ if not visited[child] then
3522
+ if checkCycle(child, path .. " -> " .. child) then return true end
3523
+ elseif recStack[child] then
3524
+ cyclePath = path .. " -> " .. child
3525
+ return true
3526
+ end
3527
+ end
3528
+ recStack[node] = false
3529
+ return false
3530
+ end
3531
+
3532
+ for _, root in ipairs(rootCandidates) do
3533
+ if checkCycle(root, root) then
3534
+ hasCycle = true
3535
+ break
3536
+ end
3537
+ end
3538
+
3539
+ local humanoid = model:FindFirstChildOfClass("Humanoid")
3540
+ local rigType = humanoid and (humanoid.RigType == Enum.HumanoidRigType.R15 and "R15" or "R6") or "Custom"
3541
+
3542
+ local warnings: { string } = {}
3543
+ if hasCycle then
3544
+ table.insert(warnings, "Ditemukan siklus tertutup (Circular Dependency): " .. cyclePath)
3545
+ end
3546
+ if anchoredCount > 1 then
3547
+ table.insert(warnings, string.format("Ada %d part yang masih 'Anchored = true'. Karakter mungkin tidak dapat bergerak/beranimasi.", anchoredCount))
3548
+ end
3549
+ if #rootCandidates > 1 then
3550
+ table.insert(warnings, "Ditemukan lebih dari 1 calon root part: " .. table.concat(rootCandidates, ", "))
3551
+ end
3552
+
3553
+ return {
3554
+ status = "success",
3555
+ modelName = model.Name,
3556
+ rigType = rigType,
3557
+ totalParts = totalParts,
3558
+ totalJoints = #joints,
3559
+ rootParts = rootCandidates,
3560
+ hasCycle = hasCycle,
3561
+ warnings = warnings,
3562
+ joints = joints,
3563
+ parts = parts
3564
+ }
3565
+ end
3566
+
3567
+ -- ── 2. Buat Sambungan Sendi Baru (Create Joint / Weld) ─────────────────────────
3568
+ function Dispatcher.createJoint(targetModel: Instance, params: { [string]: any }): { [string]: any }
3569
+ local part0Name = params.part0
3570
+ local part1Name = params.part1
3571
+ local jointType = params.joint_type or "Motor6D"
3572
+ local jointName = params.name
3573
+
3574
+ if not part0Name or not part1Name then
3575
+ return { status = "error", error = "Parameter 'part0' dan 'part1' wajib disertakan." }
3576
+ end
3577
+
3578
+ local part0 = targetModel:FindFirstChild(part0Name, true) :: BasePart?
3579
+ local part1 = targetModel:FindFirstChild(part1Name, true) :: BasePart?
3580
+
3581
+ if not part0 or not part0:IsA("BasePart") then
3582
+ return { status = "error", error = "Part0 '" .. tostring(part0Name) .. "' tidak ditemukan." }
3583
+ end
3584
+ if not part1 or not part1:IsA("BasePart") then
3585
+ return { status = "error", error = "Part1 '" .. tostring(part1Name) .. "' tidak ditemukan." }
3586
+ end
3587
+
3588
+ part1.Anchored = false
3589
+ local joint: JointInstance
3590
+ if jointType == "Weld" then
3591
+ joint = Instance.new("Weld")
3592
+ else
3593
+ joint = Instance.new("Motor6D")
3594
+ end
3595
+
3596
+ joint.Name = jointName or (jointType == "Motor6D" and part1.Name or (part0.Name .. "_" .. part1.Name))
3597
+ joint.Part0 = part0
3598
+ joint.Part1 = part1
3599
+ local c0, c1 = JointMath.computeInitialOffsets(part0, part1)
3600
+ joint.C0 = c0
3601
+ joint.C1 = c1
3602
+ joint.Parent = part0
3603
+
3604
+ return {
3605
+ status = "success",
3606
+ jointName = joint.Name,
3607
+ jointType = joint.ClassName,
3608
+ part0 = part0.Name,
3609
+ part1 = part1.Name,
3610
+ message = string.format("Berhasil membuat %s '%s' antara '%s' dan '%s'", joint.ClassName, joint.Name, part0.Name, part1.Name)
3611
+ }
3612
+ end
3613
+
3614
+ -- ── 3. Edit Poros Sendi (Offset World / Target Position) ───────────────────────
3615
+ function Dispatcher.editPivot(targetModel: Instance, params: { [string]: any }): { [string]: any }
3616
+ local jointName = params.joint_name or params.name
3617
+ if not jointName then
3618
+ return { status = "error", error = "Parameter 'joint_name' wajib diisi." }
3619
+ end
3620
+
3621
+ local joint: JointInstance? = nil
3622
+ for _, desc in ipairs(targetModel:GetDescendants()) do
3623
+ if (desc:IsA("Motor6D") or desc:IsA("Weld")) and desc.Name == jointName then
3624
+ joint = desc
3625
+ break
3626
+ end
3627
+ end
3628
+
3629
+ if not joint then
3630
+ return { status = "error", error = "Joint '" .. tostring(jointName) .. "' tidak ditemukan pada model." }
3631
+ end
3632
+
3633
+ local success = false
3634
+ if params.delta_world and type(params.delta_world) == "table" and #params.delta_world >= 3 then
3635
+ local d = params.delta_world
3636
+ success = JointMath.offsetPivot(joint, Vector3.new(d[1], d[2], d[3]))
3637
+ elseif params.target_world and type(params.target_world) == "table" and #params.target_world >= 3 then
3638
+ local p = params.target_world
3639
+ success = JointMath.setPivotWorld(joint, Vector3.new(p[1], p[2], p[3]))
3640
+ elseif params.rotate_euler_deg and type(params.rotate_euler_deg) == "table" and #params.rotate_euler_deg >= 3 then
3641
+ local r = params.rotate_euler_deg
3642
+ local rad = Vector3.new(math.rad(r[1]), math.rad(r[2]), math.rad(r[3]))
3643
+ success = JointMath.rotatePivot(joint, rad)
3644
+ elseif params.reset == true then
3645
+ success = JointMath.resetPivot(joint)
3646
+ elseif params.align_to == "Part0" or params.align_to == "Part1" then
3647
+ success = JointMath.alignJoint(joint, params.align_to)
3648
+ else
3649
+ return { status = "error", error = "Tindakan edit pivot tidak spesifik. Berikan delta_world, target_world, rotate_euler_deg, reset, atau align_to." }
3650
+ end
3651
+
3652
+ if not success then
3653
+ return { status = "error", error = "Gagal memodifikasi poros sendi '" .. jointName .. "'." }
3654
+ end
3655
+
3656
+ local newPivot = JointMath.getJointWorldPosition(joint)
3657
+ return {
3658
+ status = "success",
3659
+ jointName = joint.Name,
3660
+ newPivotWorld = newPivot and { newPivot.X, newPivot.Y, newPivot.Z } or nil,
3661
+ newC0 = { joint.C0:GetComponents() },
3662
+ newC1 = { joint.C1:GetComponents() },
3663
+ message = "Poros sendi '" .. joint.Name .. "' berhasil diperbarui tanpa dislokasi part."
3664
+ }
3665
+ end
3666
+
3667
+ -- ── 4. Cerminkan Sendi Simetri Bilateral (Mirror Joints) ──────────────────────
3668
+ function Dispatcher.mirrorJoints(targetModel: Instance, params: { [string]: any }): { [string]: any }
3669
+ local sourceSide = params.source_side or "Left"
3670
+ local targetSide = sourceSide == "Left" and "Right" or "Left"
3671
+
3672
+ local mirroredCount = 0
3673
+ local pairsProcessed: { string } = {}
3674
+
3675
+ for _, desc in ipairs(targetModel:GetDescendants()) do
3676
+ if desc:IsA("Motor6D") or desc:IsA("Weld") then
3677
+ if string.find(desc.Name, sourceSide) then
3678
+ local targetName = string.gsub(desc.Name, sourceSide, targetSide)
3679
+ local targetJoint: JointInstance? = nil
3680
+ for _, d2 in ipairs(targetModel:GetDescendants()) do
3681
+ if (d2:IsA("Motor6D") or d2:IsA("Weld")) and d2.Name == targetName then
3682
+ targetJoint = d2
3683
+ break
3684
+ end
3685
+ end
3686
+
3687
+ if targetJoint then
3688
+ JointMath.mirrorJoint(desc, targetJoint)
3689
+ mirroredCount += 1
3690
+ table.insert(pairsProcessed, desc.Name .. " -> " .. targetName)
3691
+ end
3692
+ end
3693
+ end
3694
+ end
3695
+
3696
+ return {
3697
+ status = "success",
3698
+ sourceSide = sourceSide,
3699
+ targetSide = targetSide,
3700
+ mirroredCount = mirroredCount,
3701
+ pairs = pairsProcessed,
3702
+ message = string.format("Berhasil mencerminkan %d sendi dari %s ke %s", mirroredCount, sourceSide, targetSide)
3703
+ }
3704
+ end
3705
+
3706
+ -- ── 5. Router Eksekusi Utama (Modular Dispatcher) ─────────────────────────────
3707
+ function Dispatcher.dispatch(targetInstance: Instance, action: string, data: { [string]: any }): { [string]: any }
3708
+ if action == "inspect" then
3709
+ return Dispatcher.inspectRig(targetInstance)
3710
+ elseif action == "create_joint" or action == "create" then
3711
+ return Dispatcher.createJoint(targetInstance, data)
3712
+ elseif action == "create_weld" then
3713
+ data.joint_type = "Weld"
3714
+ return Dispatcher.createJoint(targetInstance, data)
3715
+ elseif action == "edit_pivot" or action == "edit" or action == "move_pivot" or action == "rotate_pivot" or action == "reset_pivot" or action == "align_joint" then
3716
+ return Dispatcher.editPivot(targetInstance, data)
3717
+ elseif action == "mirror" or action == "mirror_joints" then
3718
+ return Dispatcher.mirrorJoints(targetInstance, data)
3719
+ elseif action == "auto_rig_r6" or action == "rig_r6" then
3720
+ return AutoRigger.rigR6(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any)
3721
+ elseif action == "auto_rig_r15" or action == "rig_r15" then
3722
+ return AutoRigger.rigR15(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any)
3723
+ elseif action == "rig_weapon" or action == "attach_weapon" then
3724
+ local weapon = data.weapon_path and game:GetService("HttpService") -- akan di-resolve oleh caller jika ada path
3725
+ return AutoRigger.rigWeapon(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any, targetInstance, data.target_limb)
3726
+ elseif action == "weld_assembly" or action == "weld_all" then
3727
+ return AutoRigger.weldAssembly(targetInstance, nil)
3728
+ elseif action == "remove_joint" or action == "delete_joint" then
3729
+ local jointName = data.joint_name or data.name
3730
+ local removed = 0
3731
+ for _, desc in ipairs(targetInstance:GetDescendants()) do
3732
+ if (desc:IsA("Motor6D") or desc:IsA("Weld")) and (not jointName or desc.Name == jointName) then
3733
+ if data.reanchor == true and desc.Part1 then
3734
+ desc.Part1.Anchored = true
3735
+ end
3736
+ desc:Destroy()
3737
+ removed += 1
3738
+ end
3739
+ end
3740
+ return { status = "success", removedCount = removed, message = string.format("Berhasil menghapus %d joint", removed) }
3741
+ else
3742
+ return {
3743
+ status = "error",
3744
+ error = "Action tidak dikenal: '" .. tostring(action) .. "'. Pilihan: inspect, create_joint, create_weld, edit_pivot, auto_rig_r6, auto_rig_r15, weld_assembly, mirror_joints, remove_joint."
3745
+ }
3746
+ end
3747
+ end
3748
+
3749
+ return Dispatcher
3750
+
3751
+ ]]></ProtectedString>
3752
+ </Properties>
3753
+ </Item>
3754
+ <Item class="ModuleScript" referent="RBX_nv_vfx_serializer">
3755
+ <Properties>
3756
+ <BinaryString name="AttributesSerialize"></BinaryString>
3757
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
3758
+ <bool name="DefinesCapabilities">false</bool>
3759
+ <bool name="Disabled">false</bool>
3760
+ <Content name="LinkedSource"><null></null></Content>
3761
+ <string name="Name">nv_vfx_serializer</string>
3762
+ <int64 name="SourceAssetId">-1</int64>
3763
+ <BinaryString name="Tags"></BinaryString>
3764
+ <ProtectedString name="Source"><![CDATA[--!strict
3765
+ -- ══════════════════════════════════════════════════════════════════════
3766
+ -- NVStudio Subsystem: nv_vfx_serializer
3767
+ -- Deskripsi: Serializer & Deserializer 1:1 Efek Visual Roblox
3768
+ -- Mengonversi seluruh properti ParticleEmitter, Beam, Trail, Light,
3769
+ -- dan Attachment menjadi tabel data murni (JSON-safe) dan merekonstruksinya
3770
+ -- dengan fidelitas 100% pada proyek Roblox mana saja.
3771
+ -- ══════════════════════════════════════════════════════════════════════
3772
+
3773
+ local Serializer = {}
3774
+
3775
+ -- ── 1. Property Whitelist Per ClassName ───────────────────────────────────────
3776
+ local PROPERTY_WHITELISTS: { [string]: { string } } = {
3777
+ ParticleEmitter = {
3778
+ "Name", "Enabled", "Texture", "Color", "Size", "Transparency",
3779
+ "Acceleration", "Drag", "EmissionDirection", "Lifetime",
3780
+ "LightEmission", "LightInfluence", "Orientation", "Rate",
3781
+ "RotSpeed", "Rotation", "Shape", "ShapeInOut", "ShapePartial",
3782
+ "ShapeStyle", "Speed", "SpreadAngle", "Squash",
3783
+ "FlipbookFramerate", "FlipbookLayout", "FlipbookMode", "FlipbookStartRandom",
3784
+ "ZOffset", "Brightness", "TimeScale", "VelocityInheritance", "WindAffects"
3785
+ },
3786
+ Beam = {
3787
+ "Name", "Enabled", "Attachment0", "Attachment1", "Color",
3788
+ "CurveSize0", "CurveSize1", "FaceCamera", "LightEmission",
3789
+ "LightInfluence", "Segments", "Texture", "TextureLength",
3790
+ "TextureMode", "TextureSpeed", "Transparency", "Width0",
3791
+ "Width1", "ZOffset", "Brightness"
3792
+ },
3793
+ Trail = {
3794
+ "Name", "Enabled", "Attachment0", "Attachment1", "Color",
3795
+ "FaceCamera", "LightEmission", "LightInfluence", "Lifetime",
3796
+ "MaxLength", "MinLength", "Texture", "TextureLength",
3797
+ "TextureMode", "Transparency", "WidthScale", "Brightness"
3798
+ },
3799
+ Attachment = {
3800
+ "Name", "Visible", "CFrame"
3801
+ },
3802
+ PointLight = {
3803
+ "Name", "Enabled", "Brightness", "Color", "Range", "Shadows"
3804
+ },
3805
+ SpotLight = {
3806
+ "Name", "Enabled", "Brightness", "Color", "Range", "Shadows", "Angle", "Face"
3807
+ },
3808
+ SurfaceLight = {
3809
+ "Name", "Enabled", "Brightness", "Color", "Range", "Shadows", "Face"
3810
+ },
3811
+ Highlight = {
3812
+ "Name", "Enabled", "FillColor", "OutlineColor", "FillTransparency", "OutlineTransparency", "DepthMode"
3813
+ }
3814
+ }
3815
+
3816
+ -- ── 2. Helper: Encode Roblox Values ke Pure Table ─────────────────────────────
3817
+ local function encodeValue(val: any, nodeIndexMap: { [Instance]: number }): any
3818
+ local valType = typeof(val)
3819
+
3820
+ if valType == "nil" or valType == "string" or valType == "number" or valType == "boolean" then
3821
+ return val
3822
+ elseif valType == "Vector3" then
3823
+ return { t = "Vector3", v = { val.X, val.Y, val.Z } }
3824
+ elseif valType == "Vector2" then
3825
+ return { t = "Vector2", v = { val.X, val.Y } }
3826
+ elseif valType == "CFrame" then
3827
+ return { t = "CFrame", v = { val:GetComponents() } }
3828
+ elseif valType == "Color3" then
3829
+ return { t = "Color3", v = { val.R, val.G, val.B } }
3830
+ elseif valType == "NumberRange" then
3831
+ return { t = "NumberRange", v = { val.Min, val.Max } }
3832
+ elseif valType == "NumberSequence" then
3833
+ local kps = {}
3834
+ for _, kp in ipairs(val.Keypoints) do
3835
+ table.insert(kps, { kp.Time, kp.Value, kp.Envelope })
3836
+ end
3837
+ return { t = "NumberSequence", v = kps }
3838
+ elseif valType == "ColorSequence" then
3839
+ local kps = {}
3840
+ for _, kp in ipairs(val.Keypoints) do
3841
+ table.insert(kps, {
3842
+ kp.Time,
3843
+ { t = "Color3", v = { kp.Value.R, kp.Value.G, kp.Value.B } }
3844
+ })
3845
+ end
3846
+ return { t = "ColorSequence", v = kps }
3847
+ elseif valType == "EnumItem" then
3848
+ return { t = "EnumItem", e = tostring(val.EnumType), v = val.Name }
3849
+ elseif valType == "Instance" then
3850
+ local idx = nodeIndexMap[val]
3851
+ if idx then
3852
+ return { t = "Ref", v = idx }
3853
+ end
3854
+ return { t = "InstanceName", v = val.Name }
3855
+ end
3856
+
3857
+ return tostring(val)
3858
+ end
3859
+
3860
+ -- ── 3. Helper: Decode Pure Table ke Roblox Values ──────────────────────────────
3861
+ local function decodeValue(val: any, createdNodes: { [number]: Instance }, scale: number?): any
3862
+ if type(val) ~= "table" or not val.t then
3863
+ return val
3864
+ end
3865
+
3866
+ local t = val.t
3867
+ local v = val.v
3868
+ local s = scale or 1
3869
+
3870
+ if t == "Vector3" then
3871
+ return Vector3.new(v[1] * s, v[2] * s, v[3] * s)
3872
+ elseif t == "Vector2" then
3873
+ return Vector2.new(v[1], v[2])
3874
+ elseif t == "CFrame" then
3875
+ local c = v
3876
+ if s ~= 1 then
3877
+ return CFrame.new(c[1] * s, c[2] * s, c[3] * s, c[4], c[5], c[6], c[7], c[8], c[9], c[10], c[11], c[12])
3878
+ end
3879
+ return CFrame.new(table.unpack(c))
3880
+ elseif t == "Color3" then
3881
+ return Color3.new(v[1], v[2], v[3])
3882
+ elseif t == "NumberRange" then
3883
+ local minV = tonumber(v[1]) or 0
3884
+ local maxV = tonumber(v[2]) or minV
3885
+ return NumberRange.new(math.min(minV, maxV), math.max(minV, maxV))
3886
+ elseif t == "NumberSequence" then
3887
+ local kps = {}
3888
+ for _, kp in ipairs(v) do
3889
+ if type(kp) == "table" then
3890
+ local timeVal = math.clamp(tonumber(kp[1]) or 0, 0, 1)
3891
+ local numVal = (tonumber(kp[2]) or 0) * s
3892
+ local envVal = (tonumber(kp[3]) or 0) * s
3893
+ table.insert(kps, NumberSequenceKeypoint.new(timeVal, numVal, envVal))
3894
+ end
3895
+ end
3896
+ if #kps >= 2 then
3897
+ table.sort(kps, function(a, b) return a.Time < b.Time end)
3898
+ if kps[1].Time ~= 0 then
3899
+ kps[1] = NumberSequenceKeypoint.new(0, kps[1].Value, kps[1].Envelope)
3900
+ end
3901
+ if kps[#kps].Time ~= 1 then
3902
+ kps[#kps] = NumberSequenceKeypoint.new(1, kps[#kps].Value, kps[#kps].Envelope)
3903
+ end
3904
+ for i = 2, #kps do
3905
+ if kps[i].Time <= kps[i - 1].Time then
3906
+ local newT = math.min(1.0, kps[i - 1].Time + 0.001)
3907
+ kps[i] = NumberSequenceKeypoint.new(newT, kps[i].Value, kps[i].Envelope)
3908
+ end
3909
+ end
3910
+ return NumberSequence.new(kps)
3911
+ elseif #kps == 1 then
3912
+ return NumberSequence.new(kps[1].Value)
3913
+ end
3914
+ return NumberSequence.new(0)
3915
+ elseif t == "ColorSequence" then
3916
+ local kps = {}
3917
+ for _, kp in ipairs(v) do
3918
+ if type(kp) == "table" and kp[2] then
3919
+ local timeVal = math.clamp(tonumber(kp[1]) or 0, 0, 1)
3920
+ local col = kp[2].v or { 1, 1, 1 }
3921
+ table.insert(kps, ColorSequenceKeypoint.new(timeVal, Color3.new(col[1] or 1, col[2] or 1, col[3] or 1)))
3922
+ end
3923
+ end
3924
+ if #kps >= 2 then
3925
+ table.sort(kps, function(a, b) return a.Time < b.Time end)
3926
+ if kps[1].Time ~= 0 then
3927
+ kps[1] = ColorSequenceKeypoint.new(0, kps[1].Value)
3928
+ end
3929
+ if kps[#kps].Time ~= 1 then
3930
+ kps[#kps] = ColorSequenceKeypoint.new(1, kps[#kps].Value)
3931
+ end
3932
+ for i = 2, #kps do
3933
+ if kps[i].Time <= kps[i - 1].Time then
3934
+ local newT = math.min(1.0, kps[i - 1].Time + 0.001)
3935
+ kps[i] = ColorSequenceKeypoint.new(newT, kps[i].Value)
3936
+ end
3937
+ end
3938
+ return ColorSequence.new(kps)
3939
+ elseif #kps == 1 then
3940
+ return ColorSequence.new(kps[1].Value)
3941
+ end
3942
+ return ColorSequence.new(Color3.new(1, 1, 1))
3943
+ elseif t == "EnumItem" then
3944
+ local enumGroup = Enum[val.e]
3945
+ if enumGroup and enumGroup[v] then
3946
+ return enumGroup[v]
3947
+ end
3948
+ return nil
3949
+ elseif t == "Ref" then
3950
+ return createdNodes[v]
3951
+ end
3952
+
3953
+ return val
3954
+ end
3955
+
3956
+ -- ── 4. Serializer: Mengonversi Instance Hierarchy Menjadi Pure Table ─────────
3957
+ function Serializer.serialize(rootInstance: Instance): { [string]: any }
3958
+ assert(typeof(rootInstance) == "Instance", "Root instance harus berupa Roblox Instance")
3959
+
3960
+ -- Kumpulkan seluruh node VFX yang didukung
3961
+ local rawNodes: { Instance } = {}
3962
+ local nodeIndexMap: { [Instance]: number } = {}
3963
+
3964
+ local function collect(inst: Instance)
3965
+ if PROPERTY_WHITELISTS[inst.ClassName] or inst:IsA("Folder") or inst:IsA("Model") or inst:IsA("Configuration") or inst == rootInstance then
3966
+ table.insert(rawNodes, inst)
3967
+ nodeIndexMap[inst] = #rawNodes
3968
+ end
3969
+ for _, child in ipairs(inst:GetChildren()) do
3970
+ collect(child)
3971
+ end
3972
+ end
3973
+
3974
+ collect(rootInstance)
3975
+
3976
+ local nodes = {}
3977
+ local stats = { emitters = 0, beams = 0, trails = 0, lights = 0 }
3978
+
3979
+ for i, inst in ipairs(rawNodes) do
3980
+ local className = inst.ClassName
3981
+ local parentIndex = nil
3982
+ if inst ~= rootInstance and inst.Parent then
3983
+ parentIndex = nodeIndexMap[inst.Parent]
3984
+ end
3985
+
3986
+ local props = {}
3987
+ local whitelist = PROPERTY_WHITELISTS[className]
3988
+ if whitelist then
3989
+ for _, propName in ipairs(whitelist) do
3990
+ local ok, propVal = pcall(function() return (inst :: any)[propName] end)
3991
+ if ok and propVal ~= nil then
3992
+ props[propName] = encodeValue(propVal, nodeIndexMap)
3993
+ end
3994
+ end
3995
+ else
3996
+ props["Name"] = inst.Name
3997
+ end
3998
+
3999
+ -- Perbarui statistik
4000
+ if className == "ParticleEmitter" then
4001
+ stats.emitters += 1
4002
+ elseif className == "Beam" then
4003
+ stats.beams += 1
4004
+ elseif className == "Trail" then
4005
+ stats.trails += 1
4006
+ elseif className:find("Light") then
4007
+ stats.lights += 1
4008
+ end
4009
+
4010
+ nodes[i] = {
4011
+ class = className,
4012
+ name = inst.Name,
4013
+ parent = parentIndex,
4014
+ properties = props
4015
+ }
4016
+ end
4017
+
4018
+ return {
4019
+ version = 1,
4020
+ rootName = rootInstance.Name,
4021
+ rootClass = rootInstance.ClassName,
4022
+ nodeCount = #nodes,
4023
+ stats = stats,
4024
+ nodes = nodes
4025
+ }
4026
+ end
4027
+
4028
+ -- ── 5. Deserializer: Merekonstruksi Roblox Instances dari Pure Table ───────────
4029
+ function Serializer.deserialize(treeData: { [string]: any }, targetParent: Instance, options: { scale: number?, enabled: boolean? }?): { [string]: any }
4030
+ assert(type(treeData) == "table" and treeData.nodes, "Format data treeData tidak valid")
4031
+ assert(typeof(targetParent) == "Instance", "TargetParent harus berupa Instance")
4032
+
4033
+ local opts = options or {}
4034
+ local scale = tonumber(opts.scale) or 1
4035
+ local createdNodes: { [number]: Instance } = {}
4036
+ local refDeferred: { { inst: Instance, propName: string, refIdx: number } } = {}
4037
+ local errors: { string } = {}
4038
+ local createdCount = 0
4039
+
4040
+ -- Pass 1: Buat semua instance dan tetapkan parent dasar
4041
+ for i, node in ipairs(treeData.nodes) do
4042
+ local ok, inst = pcall(function()
4043
+ -- Jika node pertama adalah root dan bertipe Part/Model pembungkus, kita bisa menaruh isinya langsung
4044
+ if i == 1 and (node.class == "Folder" or node.class == "Model" or node.class == "Configuration") and targetParent:IsA("BasePart") then
4045
+ -- Buat container pembungkus di targetParent
4046
+ local container = Instance.new(node.class)
4047
+ container.Name = node.name or "NV_VFX"
4048
+ container.Parent = targetParent
4049
+ return container
4050
+ elseif i == 1 and (node.class == "ParticleEmitter" or node.class == "Beam" or node.class == "Trail" or node.class == "Attachment") then
4051
+ local newInst = Instance.new(node.class)
4052
+ newInst.Name = node.name or "NV_VFX"
4053
+ newInst.Parent = targetParent
4054
+ return newInst
4055
+ else
4056
+ local newInst = Instance.new(node.class)
4057
+ newInst.Name = node.name or ("Node_" .. tostring(i))
4058
+ return newInst
4059
+ end
4060
+ end)
4061
+
4062
+ if ok and inst then
4063
+ createdNodes[i] = inst
4064
+ createdCount += 1
4065
+ else
4066
+ table.insert(errors, "Gagal membuat instance untuk node " .. tostring(i) .. ": " .. tostring(inst))
4067
+ end
4068
+ end
4069
+
4070
+ -- Pass 2: Hubungkan hierarki Parent
4071
+ for i, node in ipairs(treeData.nodes) do
4072
+ local inst = createdNodes[i]
4073
+ if inst and i > 1 then
4074
+ local parentIdx = node.parent
4075
+ if parentIdx and createdNodes[parentIdx] then
4076
+ inst.Parent = createdNodes[parentIdx]
4077
+ else
4078
+ inst.Parent = createdNodes[1] or targetParent
4079
+ end
4080
+ end
4081
+ end
4082
+
4083
+ -- Pass 3: Terapkan Properti (dengan scaling & deferred Attachment ref)
4084
+ for i, node in ipairs(treeData.nodes) do
4085
+ local inst = createdNodes[i]
4086
+ if inst and node.properties then
4087
+ for propName, propData in pairs(node.properties) do
4088
+ if type(propData) == "table" and propData.t == "Ref" then
4089
+ table.insert(refDeferred, { inst = inst, propName = propName, refIdx = propData.v })
4090
+ else
4091
+ local pScale = (propName == "Size" or propName == "Width0" or propName == "Width1" or propName == "Speed" or propName == "Acceleration") and scale or 1
4092
+ local decoded = decodeValue(propData, createdNodes, pScale)
4093
+ if decoded ~= nil then
4094
+ pcall(function()
4095
+ if propName == "Enabled" and opts.enabled ~= nil then
4096
+ (inst :: any)[propName] = opts.enabled
4097
+ else
4098
+ (inst :: any)[propName] = decoded
4099
+ end
4100
+ end)
4101
+ end
4102
+ end
4103
+ end
4104
+ end
4105
+ end
4106
+
4107
+ -- Pass 4: Hubungkan Deferred Attachment References (Beam / Trail)
4108
+ for _, ref in ipairs(refDeferred) do
4109
+ local targetNode = createdNodes[ref.refIdx]
4110
+ if targetNode and targetNode:IsA("Attachment") then
4111
+ pcall(function()
4112
+ (ref.inst :: any)[ref.propName] = targetNode
4113
+ end)
4114
+ end
4115
+ end
4116
+
4117
+ return {
4118
+ status = #errors == 0 and "success" or "partial",
4119
+ createdCount = createdCount,
4120
+ root = createdNodes[1] or targetParent,
4121
+ errors = errors
4122
+ }
4123
+ end
4124
+
4125
+ return Serializer
4126
+
4127
+ ]]></ProtectedString>
4128
+ </Properties>
4129
+ </Item>
4130
+ <Item class="ModuleScript" referent="RBX_patch_script_source">
4131
+ <Properties>
4132
+ <BinaryString name="AttributesSerialize"></BinaryString>
4133
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
4134
+ <bool name="DefinesCapabilities">false</bool>
4135
+ <bool name="Disabled">false</bool>
4136
+ <Content name="LinkedSource"><null></null></Content>
4137
+ <string name="Name">patch_script_source</string>
4138
+ <int64 name="SourceAssetId">-1</int64>
4139
+ <BinaryString name="Tags"></BinaryString>
4140
+ <ProtectedString name="Source"><![CDATA[local HttpService = game:GetService("HttpService")
4141
+
4142
+ return function(targetInstance, data, ctx, targetPath)
4143
+ if not targetInstance:IsA("LuaSourceContainer") then
4144
+ return { status = "error", error = "Target BUKAN Script (ClassName: " .. targetInstance.ClassName .. ")." }
4145
+ end
4146
+
4147
+ local payload = nil
4148
+ pcall(function()
4149
+ payload = HttpService:JSONDecode(data)
4150
+ end)
4151
+
4152
+ if not payload then
4153
+ return { status = "error", error = "Payload data harus berupa JSON valid." }
4154
+ end
4155
+
4156
+ local currentSource = targetInstance.Source
4157
+ local newSource = currentSource
4158
+ local affectedLines = 0
4159
+
4160
+ -- Mode 1: Search & Replace Exact Chunk
4161
+ if payload.target_content and payload.replacement_content then
4162
+ local targetContent = payload.target_content
4163
+ local replacementContent = payload.replacement_content
4164
+ local allowMultiple = payload.allow_multiple or false
4165
+
4166
+ if targetContent == "" then
4167
+ return { status = "error", error = "target_content tidak boleh berupa string kosong." }
4168
+ end
4169
+
4170
+ -- Escape magic characters for string.gsub if needed, or use string.find manually.
4171
+ -- string.gsub doesn't do plain text replacement directly without escaping.
4172
+ -- We'll write a plain text replace function.
4173
+
4174
+ local function plainReplace(str, pattern, repl, max)
4175
+ local res = ""
4176
+ local startIdx = 1
4177
+ local count = 0
4178
+ while true do
4179
+ if max and count >= max then break end
4180
+ local findStart, findEnd = string.find(str, pattern, startIdx, true) -- true for plain match
4181
+ if not findStart then break end
4182
+
4183
+ res = res .. string.sub(str, startIdx, findStart - 1) .. repl
4184
+ startIdx = findEnd + 1
4185
+ count = count + 1
4186
+ end
4187
+ res = res .. string.sub(str, startIdx)
4188
+ return res, count
4189
+ end
1383
4190
 
1384
4191
  local function countOccurrences(str, pattern)
1385
4192
  local count = 0
@@ -1447,6 +4254,118 @@ return function(targetInstance, data, ctx, targetPath)
1447
4254
  affected_lines = affectedLines
1448
4255
  }
1449
4256
  end
4257
+ ]]></ProtectedString>
4258
+ </Properties>
4259
+ </Item>
4260
+ <Item class="ModuleScript" referent="RBX_preview_asset">
4261
+ <Properties>
4262
+ <BinaryString name="AttributesSerialize"></BinaryString>
4263
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
4264
+ <bool name="DefinesCapabilities">false</bool>
4265
+ <bool name="Disabled">false</bool>
4266
+ <Content name="LinkedSource"><null></null></Content>
4267
+ <string name="Name">preview_asset</string>
4268
+ <int64 name="SourceAssetId">-1</int64>
4269
+ <BinaryString name="Tags"></BinaryString>
4270
+ <ProtectedString name="Source"><![CDATA[--!strict
4271
+ -- ============================================================================
4272
+ -- Skill Module: preview_asset
4273
+ -- Deskripsi: Pratinjau & Pemindaian Keamanan Aset Creator Store Secara Terisolasi
4274
+ -- ============================================================================
4275
+
4276
+ local HttpService = game:GetService("HttpService")
4277
+ local InsertService = game:GetService("InsertService")
4278
+
4279
+ local SecurityScanner = require(script.Parent:WaitForChild("security_scanner"))
4280
+
4281
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
4282
+ local assetIdStr = ""
4283
+ local deepScan = true
4284
+
4285
+ if typeof(data) == "string" then
4286
+ local success, parsed = pcall(function()
4287
+ return HttpService:JSONDecode(data)
4288
+ end)
4289
+ if success and typeof(parsed) == "table" then
4290
+ assetIdStr = tostring(parsed.asset_id or parsed.assetId or "")
4291
+ if parsed.deep_scan ~= nil then deepScan = parsed.deep_scan end
4292
+ else
4293
+ assetIdStr = data
4294
+ end
4295
+ elseif typeof(data) == "table" then
4296
+ assetIdStr = tostring(data.asset_id or data.assetId or "")
4297
+ if data.deep_scan ~= nil then deepScan = data.deep_scan end
4298
+ end
4299
+
4300
+ local cleanIdStr = string.gsub(assetIdStr, "rbxassetid://", "")
4301
+ cleanIdStr = string.gsub(cleanIdStr, "%s+", "")
4302
+ local assetId = tonumber(cleanIdStr)
4303
+
4304
+ if not assetId or assetId <= 0 then
4305
+ return { status = "error", error = "Asset ID tidak valid. Berikan Asset ID angka (contoh: 123456789)." }
4306
+ end
4307
+
4308
+ -- Muat Aset secara terisolasi (unparented / memory only)
4309
+ local ok, model = pcall(function()
4310
+ return InsertService:LoadAsset(assetId)
4311
+ end)
4312
+
4313
+ if not ok or not model then
4314
+ return {
4315
+ status = "error",
4316
+ error = string.format("Gagal memuat aset ID %d. Pastikan Asset ID publik dan terdaftar di Creator Store.", assetId)
4317
+ }
4318
+ end
4319
+
4320
+ local scanResult
4321
+ local scanOk, err = pcall(function()
4322
+ scanResult = SecurityScanner.scanInstance(model, deepScan)
4323
+ end)
4324
+
4325
+ local rootName = model.Name
4326
+ local childrenOverview = {}
4327
+ for _, child in ipairs(model:GetChildren()) do
4328
+ table.insert(childrenOverview, {
4329
+ name = child.Name,
4330
+ className = child.ClassName,
4331
+ })
4332
+ end
4333
+
4334
+ -- Hancurkan model sementara dari memori (Cleanup Mutlak)
4335
+ pcall(function()
4336
+ model:Destroy()
4337
+ end)
4338
+
4339
+ if not scanOk or not scanResult then
4340
+ return {
4341
+ status = "error",
4342
+ error = "Gagal memindai aset: " .. tostring(err)
4343
+ }
4344
+ end
4345
+
4346
+ local recommendation = "Aman untuk dimasukkan langsung menggunakan insert_asset."
4347
+ if scanResult.riskLevel == "DANGEROUS" then
4348
+ recommendation = "PERINGATAN: Aset ini memiliki ancaman BERBAHAYA! Wajib gunakan 'insert_asset' dengan strip_scripts = true, atau hindari aset ini."
4349
+ elseif scanResult.riskLevel == "WARNING" then
4350
+ recommendation = "Perhatian: Aset mengandung skrip atau peringatan moderat. Periksa kembali atau gunakan strip_scripts = true jika skrip tidak diperlukan."
4351
+ end
4352
+
4353
+ return {
4354
+ status = "success",
4355
+ result = {
4356
+ assetId = assetId,
4357
+ assetName = rootName,
4358
+ riskLevel = scanResult.riskLevel,
4359
+ recommendation = recommendation,
4360
+ stats = scanResult.stats,
4361
+ highThreatsCount = scanResult.highThreats,
4362
+ mediumThreatsCount = scanResult.mediumThreats,
4363
+ threats = scanResult.threats,
4364
+ topLevelChildren = childrenOverview,
4365
+ }
4366
+ }
4367
+ end
4368
+
1450
4369
  ]]></ProtectedString>
1451
4370
  </Properties>
1452
4371
  </Item>
@@ -1479,6 +4398,198 @@ end
1479
4398
  return { status = "error", error = "Target BUKAN Script (ClassName: " .. targetInstance.ClassName .. ")." }
1480
4399
  end
1481
4400
  end
4401
+ ]]></ProtectedString>
4402
+ </Properties>
4403
+ </Item>
4404
+ <Item class="ModuleScript" referent="RBX_scan_security">
4405
+ <Properties>
4406
+ <BinaryString name="AttributesSerialize"></BinaryString>
4407
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
4408
+ <bool name="DefinesCapabilities">false</bool>
4409
+ <bool name="Disabled">false</bool>
4410
+ <Content name="LinkedSource"><null></null></Content>
4411
+ <string name="Name">scan_security</string>
4412
+ <int64 name="SourceAssetId">-1</int64>
4413
+ <BinaryString name="Tags"></BinaryString>
4414
+ <ProtectedString name="Source"><![CDATA[--!strict
4415
+ -- ============================================================================
4416
+ -- Skill Module: scan_security
4417
+ -- Deskripsi: Audit Keamanan Instance/Model yang Sudah Ada di Tempat Kerja Studio
4418
+ -- ============================================================================
4419
+
4420
+ local HttpService = game:GetService("HttpService")
4421
+ local SecurityScanner = require(script.Parent:WaitForChild("security_scanner"))
4422
+
4423
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
4424
+ local target = targetInstance or game.Workspace
4425
+ local deepScan = true
4426
+ local cleanThreats = false
4427
+
4428
+ if typeof(data) == "string" and #data > 0 then
4429
+ local success, parsed = pcall(function()
4430
+ return HttpService:JSONDecode(data)
4431
+ end)
4432
+ if success and typeof(parsed) == "table" then
4433
+ if parsed.deep_scan ~= nil then deepScan = parsed.deep_scan end
4434
+ if parsed.clean_threats ~= nil then cleanThreats = parsed.clean_threats end
4435
+ end
4436
+ elseif typeof(data) == "table" then
4437
+ if data.deep_scan ~= nil then deepScan = data.deep_scan end
4438
+ if data.clean_threats ~= nil then cleanThreats = data.clean_threats end
4439
+ end
4440
+
4441
+ local scanResult
4442
+ local ok, err = pcall(function()
4443
+ scanResult = SecurityScanner.scanInstance(target, deepScan)
4444
+ end)
4445
+
4446
+ if not ok or not scanResult then
4447
+ return {
4448
+ status = "error",
4449
+ error = "Gagal memindai target: " .. tostring(err)
4450
+ }
4451
+ end
4452
+
4453
+ local cleanedScripts = {}
4454
+ if cleanThreats and #scanResult.threats > 0 then
4455
+ -- Hapus skrip yang terdeteksi bahaya (HIGH severity)
4456
+ local scriptsToDestroy = {}
4457
+ for _, threat in ipairs(scanResult.threats) do
4458
+ if threat.severity == "HIGH" and threat.scriptPath then
4459
+ scriptsToDestroy[threat.scriptPath] = true
4460
+ end
4461
+ end
4462
+
4463
+ local function purge(instance: Instance)
4464
+ for _, desc in ipairs(instance:GetDescendants()) do
4465
+ if desc:IsA("LuaSourceContainer") and scriptsToDestroy[desc:GetFullName()] then
4466
+ table.insert(cleanedScripts, {
4467
+ name = desc.Name,
4468
+ className = desc.ClassName,
4469
+ path = desc:GetFullName(),
4470
+ })
4471
+ pcall(function() desc:Destroy() end)
4472
+ end
4473
+ end
4474
+ end
4475
+
4476
+ purge(target)
4477
+ end
4478
+
4479
+ return {
4480
+ status = "success",
4481
+ result = {
4482
+ target = target:GetFullName(),
4483
+ riskLevel = scanResult.riskLevel,
4484
+ stats = scanResult.stats,
4485
+ highThreatsCount = scanResult.highThreats,
4486
+ mediumThreatsCount = scanResult.mediumThreats,
4487
+ threats = scanResult.threats,
4488
+ cleanedThreatsCount = #cleanedScripts,
4489
+ cleanedScripts = cleanedScripts,
4490
+ isCleaned = cleanThreats,
4491
+ }
4492
+ }
4493
+ end
4494
+
4495
+ ]]></ProtectedString>
4496
+ </Properties>
4497
+ </Item>
4498
+ <Item class="ModuleScript" referent="RBX_scan_vfx">
4499
+ <Properties>
4500
+ <BinaryString name="AttributesSerialize"></BinaryString>
4501
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
4502
+ <bool name="DefinesCapabilities">false</bool>
4503
+ <bool name="Disabled">false</bool>
4504
+ <Content name="LinkedSource"><null></null></Content>
4505
+ <string name="Name">scan_vfx</string>
4506
+ <int64 name="SourceAssetId">-1</int64>
4507
+ <BinaryString name="Tags"></BinaryString>
4508
+ <ProtectedString name="Source"><![CDATA[--!strict
4509
+ -- ══════════════════════════════════════════════════════════════════════
4510
+ -- Skill Module: scan_vfx
4511
+ -- Deskripsi: Memindai efek visual (ParticleEmitter, Beam, Trail, Light)
4512
+ -- pada objek target di Roblox Studio dan mengubahnya menjadi tabel murni
4513
+ -- ══════════════════════════════════════════════════════════════════════
4514
+
4515
+ local HttpService = game:GetService("HttpService")
4516
+ local serializerMod = script.Parent:WaitForChild("nv_vfx_serializer")
4517
+ local Serializer = require(serializerMod)
4518
+
4519
+ local function detectCategory(name: string, data: any): string
4520
+ local lower = string.lower(name)
4521
+ if lower:find("slash") or lower:find("blade") or lower:find("sword") or lower:find("cut") then
4522
+ return "Slash"
4523
+ elseif lower:find("explosion") or lower:find("burst") or lower:find("blast") or lower:find("boom") or lower:find("fire") then
4524
+ return "Explosion"
4525
+ elseif lower:find("aura") or lower:find("glow") or lower:find("shield") or lower:find("barrier") then
4526
+ return "Aura"
4527
+ elseif lower:find("laser") or lower:find("beam") or lower:find("ray") then
4528
+ return "Beam"
4529
+ elseif lower:find("magic") or lower:find("spell") or lower:find("rune") or lower:find("circle") or lower:find("portal") then
4530
+ return "Magic"
4531
+ elseif lower:find("hit") or lower:find("impact") or lower:find("spark") or lower:find("clash") then
4532
+ return "Impact"
4533
+ elseif lower:find("projectile") or lower:find("bullet") or lower:find("arrow") or lower:find("missile") then
4534
+ return "Projectile"
4535
+ end
4536
+ return "Ambient"
4537
+ end
4538
+
4539
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
4540
+ if not targetInstance then
4541
+ return { status = "error", error = "Target instance untuk scan_vfx tidak ditemukan: " .. tostring(targetPath) }
4542
+ end
4543
+
4544
+ local parsedData = {}
4545
+ if data and data ~= "" then
4546
+ pcall(function()
4547
+ if type(data) == "table" then
4548
+ parsedData = data
4549
+ else
4550
+ parsedData = HttpService:JSONDecode(tostring(data))
4551
+ end
4552
+ end)
4553
+ end
4554
+
4555
+ local vfxName = parsedData.name or targetInstance.Name or "Unnamed_VFX"
4556
+ local rawCat = parsedData.category or detectCategory(vfxName, parsedData)
4557
+ local category = "Ambient"
4558
+ if type(rawCat) == "string" and #rawCat > 0 then
4559
+ category = string.upper(string.sub(rawCat, 1, 1)) .. string.lower(string.sub(rawCat, 2))
4560
+ end
4561
+ local tags = parsedData.tags or { string.lower(category) }
4562
+
4563
+ local ok, serialized = pcall(function()
4564
+ return Serializer.serialize(targetInstance)
4565
+ end)
4566
+
4567
+ if not ok or not serialized then
4568
+ return { status = "error", error = "Gagal memindai VFX: " .. tostring(serialized) }
4569
+ end
4570
+
4571
+ if serialized.nodeCount == 0 or (serialized.stats.emitters == 0 and serialized.stats.beams == 0 and serialized.stats.trails == 0 and serialized.stats.lights == 0) then
4572
+ return {
4573
+ status = "error",
4574
+ error = "Objek '" .. targetInstance.Name .. "' tidak memiliki komponen VFX (ParticleEmitter, Beam, Trail, atau Light)."
4575
+ }
4576
+ end
4577
+
4578
+ return {
4579
+ status = "success",
4580
+ result = {
4581
+ name = vfxName,
4582
+ category = category,
4583
+ tags = tags,
4584
+ description = parsedData.description or ("Scanned VFX from " .. targetInstance:GetFullName()),
4585
+ stats = serialized.stats,
4586
+ nodeCount = serialized.nodeCount,
4587
+ rootClass = serialized.rootClass,
4588
+ data = serialized
4589
+ }
4590
+ }
4591
+ end
4592
+
1482
4593
  ]]></ProtectedString>
1483
4594
  </Properties>
1484
4595
  </Item>
@@ -1572,6 +4683,141 @@ end
1572
4683
 
1573
4684
  return { status = "success", matches = matches, count = matchCount, limitReached = (matchCount >= MAX_MATCHES) }
1574
4685
  end
4686
+ ]]></ProtectedString>
4687
+ </Properties>
4688
+ </Item>
4689
+ <Item class="ModuleScript" referent="RBX_scrub_timeline">
4690
+ <Properties>
4691
+ <BinaryString name="AttributesSerialize"></BinaryString>
4692
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
4693
+ <bool name="DefinesCapabilities">false</bool>
4694
+ <bool name="Disabled">false</bool>
4695
+ <Content name="LinkedSource"><null></null></Content>
4696
+ <string name="Name">scrub_timeline</string>
4697
+ <int64 name="SourceAssetId">-1</int64>
4698
+ <BinaryString name="Tags"></BinaryString>
4699
+ <ProtectedString name="Source"><![CDATA[--!strict
4700
+ -- ══════════════════════════════════════════════════════════════════════
4701
+ -- Skill Module: scrub_timeline
4702
+ -- Deskripsi: Headless Timeline Scrubbing & VFX Playhead Controller
4703
+ -- Mengevaluasi pose rig dan memicu burst/scrub VFX pada timestamp spesifik (t)
4704
+ -- secara deterministik langsung di Viewport Roblox Studio.
4705
+ -- ══════════════════════════════════════════════════════════════════════
4706
+
4707
+ local HttpService = game:GetService("HttpService")
4708
+ local kinematicsMod = script.Parent:WaitForChild("nv_kinematics")
4709
+ local Kinematics = require(kinematicsMod)
4710
+ local curvesMod = script.Parent:WaitForChild("nv_curves")
4711
+ local Curves = require(curvesMod)
4712
+ local motionMod = script.Parent:WaitForChild("nv_motion_generator")
4713
+ local Motion = require(motionMod)
4714
+
4715
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
4716
+ if not targetInstance then
4717
+ return { status = "error", error = "Target instance untuk scrub_timeline tidak ditemukan: " .. tostring(targetPath) }
4718
+ end
4719
+
4720
+ local payload: { [string]: any } = {}
4721
+ pcall(function()
4722
+ if type(data) == "table" then payload = data
4723
+ else payload = HttpService:JSONDecode(tostring(data)) end
4724
+ end)
4725
+
4726
+ local timePos = tonumber(payload.time) or 0.0
4727
+ local action = payload.action or "melee_slash"
4728
+ local emitVfx = payload.emit_vfx == true or payload.emit == true
4729
+ local emitCount = tonumber(payload.emit_count) or 20
4730
+
4731
+ local rigInfo = Kinematics.inspectRig(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any)
4732
+ local appliedMotors: { string } = {}
4733
+
4734
+ -- 1. Scrub Rig Joint Poses jika target adalah Rig
4735
+ if rigInfo then
4736
+ local keyframes: { Motion.KeyframeData }
4737
+ if action == "walk_cycle" then
4738
+ keyframes = Motion.generateWalkCycle(rigInfo, payload.parameters)
4739
+ elseif action == "punch" then
4740
+ keyframes = Motion.generatePunch(rigInfo, payload.parameters)
4741
+ elseif action == "idle" or action == "idle_breath" then
4742
+ keyframes = Motion.generateIdle(rigInfo, payload.parameters)
4743
+ else
4744
+ keyframes = Motion.generateMeleeSlash(rigInfo, payload.parameters)
4745
+ end
4746
+
4747
+ if #keyframes > 0 then
4748
+ table.sort(keyframes, function(a, b) return a.time < b.time end)
4749
+ local totalDur = keyframes[#keyframes].time
4750
+ local clampedTime = math.clamp(timePos, 0, totalDur)
4751
+
4752
+ -- Cari segmen [kA, kB]
4753
+ local kA = keyframes[1]
4754
+ local kB = keyframes[#keyframes]
4755
+ for i = 1, #keyframes - 1 do
4756
+ if clampedTime >= keyframes[i].time and clampedTime <= keyframes[i + 1].time then
4757
+ kA = keyframes[i]
4758
+ kB = keyframes[i + 1]
4759
+ break
4760
+ end
4761
+ end
4762
+
4763
+ local segDur = kB.time - kA.time
4764
+ local alpha = segDur > 1e-5 and ((clampedTime - kA.time) / segDur) or 0
4765
+ local curvedAlpha = Curves.evaluate(payload.easing or "QuadInOut", alpha)
4766
+
4767
+ for motorName, cfA in pairs(kA.poses) do
4768
+ local cfB = kB.poses[motorName] or cfA
4769
+ local interpCF = Curves.slerp(cfA, cfB, curvedAlpha)
4770
+ local m = rigInfo.motors[motorName]
4771
+ if m then
4772
+ pcall(function()
4773
+ m.C0 = interpCF
4774
+ table.insert(appliedMotors, motorName)
4775
+ end)
4776
+ end
4777
+ end
4778
+ end
4779
+ end
4780
+
4781
+ -- 2. Scrub & Trigger VFX Emitters
4782
+ local emittersTriggered = 0
4783
+ local beamsToggled = 0
4784
+
4785
+ local function checkVfx(inst: Instance)
4786
+ if inst:IsA("ParticleEmitter") then
4787
+ if emitVfx then
4788
+ pcall(function()
4789
+ inst:Emit(emitCount)
4790
+ emittersTriggered += 1
4791
+ end)
4792
+ end
4793
+ elseif inst:IsA("Beam") or inst:IsA("Trail") then
4794
+ if payload.enabled ~= nil then
4795
+ pcall(function()
4796
+ inst.Enabled = payload.enabled
4797
+ beamsToggled += 1
4798
+ end)
4799
+ end
4800
+ end
4801
+ end
4802
+
4803
+ checkVfx(targetInstance)
4804
+ for _, desc in ipairs(targetInstance:GetDescendants()) do
4805
+ checkVfx(desc)
4806
+ end
4807
+
4808
+ return {
4809
+ status = "success",
4810
+ result = {
4811
+ time = timePos,
4812
+ action = action,
4813
+ motorsPosed = #appliedMotors,
4814
+ emittersTriggered = emittersTriggered,
4815
+ beamsToggled = beamsToggled,
4816
+ message = string.format("Timeline di-scrub ke t=%.2fs (Pose: %d joint, VFX: %d emitter burst)", timePos, #appliedMotors, emittersTriggered)
4817
+ }
4818
+ }
4819
+ end
4820
+
1575
4821
  ]]></ProtectedString>
1576
4822
  </Properties>
1577
4823
  </Item>
@@ -1624,6 +4870,365 @@ end
1624
4870
  return { status = "success", result = "Tidak ditemukan instance dengan nama: " .. searchName }
1625
4871
  end
1626
4872
  end
4873
+ ]]></ProtectedString>
4874
+ </Properties>
4875
+ </Item>
4876
+ <Item class="ModuleScript" referent="RBX_security_scanner">
4877
+ <Properties>
4878
+ <BinaryString name="AttributesSerialize"></BinaryString>
4879
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
4880
+ <bool name="DefinesCapabilities">false</bool>
4881
+ <bool name="Disabled">false</bool>
4882
+ <Content name="LinkedSource"><null></null></Content>
4883
+ <string name="Name">security_scanner</string>
4884
+ <int64 name="SourceAssetId">-1</int64>
4885
+ <BinaryString name="Tags"></BinaryString>
4886
+ <ProtectedString name="Source"><![CDATA[--!strict
4887
+ -- ============================================================================
4888
+ -- Module: security_scanner
4889
+ -- Deskripsi: Engine Heuristik Pemindaian Keamanan & Backdoor Detection
4890
+ -- Standar: Business-Grade, Zero-Execution Isolation, Comprehensive Threat Rules
4891
+ -- ============================================================================
4892
+
4893
+ local SecurityScanner = {}
4894
+
4895
+ -- Daftar nama skrip yang sering digunakan oleh virus/backdoor klasik Roblox
4896
+ local SUSPICIOUS_SCRIPT_NAMES = {
4897
+ "[Vv]accine",
4898
+ "[Aa]nti[Ll]ag",
4899
+ "[Ff]ix[Ll]ag",
4900
+ "[Ii]nfection",
4901
+ "[Ff]ire",
4902
+ "[Cc]lean",
4903
+ "[Vv]irus",
4904
+ "4[Dd]",
4905
+ "4[Dd]_[Pp]lugin",
4906
+ "[Ss]pread",
4907
+ "[Ll]ag[Ff]ixer",
4908
+ "[Dd]up",
4909
+ }
4910
+
4911
+ -- Aturan Pola Heuristik Kode Berbahaya
4912
+ local THREAT_PATTERNS = {
4913
+ {
4914
+ id = "BACKDOOR_REQUIRE",
4915
+ pattern = "require%s*%(%s*%d+%s*%)",
4916
+ severity = "HIGH",
4917
+ name = "Backdoor Numeric Require",
4918
+ description = "Memanggil modul eksternal pihak ketiga via Asset ID numerik (indikasi backdoor/remote loader).",
4919
+ },
4920
+ {
4921
+ id = "LOADSTRING_EXEC",
4922
+ pattern = "loadstring%s*%(",
4923
+ severity = "HIGH",
4924
+ name = "Dynamic Code Execution (loadstring)",
4925
+ description = "Mengeksekusi string kode dinamis yang tidak dapat diaudit.",
4926
+ },
4927
+ {
4928
+ id = "GETFENV_SETFENV",
4929
+ pattern = "[gs]etfenv%s*%(",
4930
+ severity = "HIGH",
4931
+ name = "Environment Manipulation (getfenv/setfenv)",
4932
+ description = "Memanipulasi environment global Luau untuk mengelabui analisis statis.",
4933
+ },
4934
+ {
4935
+ id = "DISCORD_WEBHOOK",
4936
+ pattern = "discord[a-z]*%.com/api/webhooks",
4937
+ severity = "HIGH",
4938
+ name = "Discord Webhook Exfiltration",
4939
+ description = "Mencoba mengirimkan data game atau informasi pemain ke webhook eksternal Discord.",
4940
+ },
4941
+ {
4942
+ id = "IP_LOGGER",
4943
+ pattern = "webhook%.site|grabify%.link|iplogger|blasze",
4944
+ severity = "HIGH",
4945
+ name = "External IP/Data Logger",
4946
+ description = "Menghubungi domain pencatat IP atau data capture pihak ketiga.",
4947
+ },
4948
+ {
4949
+ id = "FORCED_PURCHASE",
4950
+ pattern = "MarketplaceService%s*:%s*Prompt[A-Za-z]*Purchase",
4951
+ severity = "MEDIUM",
4952
+ name = "Forced Robux/Product Purchase",
4953
+ description = "Memicu dialog pembelian paksa Gamepass atau Developer Product.",
4954
+ },
4955
+ {
4956
+ id = "FORCED_TELEPORT",
4957
+ pattern = "TeleportService%s*:%s*Teleport",
4958
+ severity = "MEDIUM",
4959
+ name = "Forced Game Teleportation",
4960
+ description = "Memindahkan pemain secara paksa ke game/place lain.",
4961
+ },
4962
+ {
4963
+ id = "CHAR_TABLE_OBFUSCATION",
4964
+ pattern = "string%.char%s*%(%s*%d+%s*,%s*%d+%s*,%s*%d+",
4965
+ severity = "HIGH",
4966
+ name = "Obfuscated string.char Array",
4967
+ description = "Menyusun kode rahasia tersembunyi menggunakan deretan kode karakter ASCII.",
4968
+ },
4969
+ }
4970
+
4971
+ -- Helper: Pengecekan nama script mencurigakan
4972
+ local function checkSuspiciousName(name: string): boolean
4973
+ for _, pattern in ipairs(SUSPICIOUS_SCRIPT_NAMES) do
4974
+ if string.find(name, pattern) then
4975
+ return true
4976
+ end
4977
+ end
4978
+ return false
4979
+ end
4980
+
4981
+ -- Helper: Deteksi obfuscation byte escape (\108\111\97\100) atau hex (\x6c\x6f)
4982
+ local function checkByteObfuscation(source: string): (boolean, string?)
4983
+ local byteCount = 0
4984
+ for _ in string.gmatch(source, "\\[0-9][0-9][0-9]") do
4985
+ byteCount = byteCount + 1
4986
+ if byteCount >= 4 then
4987
+ return true, "Terdeteksi deretan escape byte ASCII (\\ddd) berulang."
4988
+ end
4989
+ end
4990
+
4991
+ local hexCount = 0
4992
+ for _ in string.gmatch(source, "\\x[0-9a-fA-F][0-9a-fA-F]") do
4993
+ hexCount = hexCount + 1
4994
+ if hexCount >= 4 then
4995
+ return true, "Terdeteksi deretan escape hex (\\xHH) berulang."
4996
+ end
4997
+ end
4998
+
4999
+ return false, nil
5000
+ end
5001
+
5002
+ -- Helper: Deteksi kode yang sengaja disembunyikan dengan spasi/baris kosong berlebih
5003
+ local function checkOffscreenHiding(source: string): (boolean, string?)
5004
+ -- Cek spasi horizontal masif (> 80 spasi berturut-turut)
5005
+ if string.find(source, " " .. string.rep(" ", 80)) then
5006
+ return true, "Kode sengaja didorong ke kanan dengan lebih dari 80 spasi agar tersembunyi dari layar Studio."
5007
+ end
5008
+
5009
+ -- Cek baris kosong masif di awal skrip (> 15 baris kosong)
5010
+ local leadingNewlines = 0
5011
+ for line in string.gmatch(source, "([^\r\n]*)\r?\n") do
5012
+ if string.gsub(line, "%s+", "") == "" then
5013
+ leadingNewlines = leadingNewlines + 1
5014
+ if leadingNewlines >= 15 then
5015
+ return true, "Terdapat lebih dari 15 baris kosong di awal skrip untuk menyembunyikan payload di baris bawah."
5016
+ end
5017
+ else
5018
+ break
5019
+ end
5020
+ end
5021
+
5022
+ return false, nil
5023
+ end
5024
+
5025
+ -- Analisis Source Code dari sebuah Script
5026
+ local function scanScriptSource(scriptInstance: Instance, source: string): { any }
5027
+ local threats = {}
5028
+ local lines = string.split(source, "\n")
5029
+
5030
+ -- 1. Scan pola heuristik baris per baris
5031
+ for lineIdx, line in ipairs(lines) do
5032
+ -- Skip baris komentar murni
5033
+ local trimmed = string.match(line, "^%s*(.-)%s*$") or ""
5034
+ if not string.sub(trimmed, 1, 2) == "--" then
5035
+ for _, rule in ipairs(THREAT_PATTERNS) do
5036
+ local s, e = string.find(line, rule.pattern)
5037
+ if s then
5038
+ local snippet = string.sub(line, math.max(1, s - 30), math.min(#line, e + 30))
5039
+ table.insert(threats, {
5040
+ severity = rule.severity,
5041
+ ruleId = rule.id,
5042
+ name = rule.name,
5043
+ scriptName = scriptInstance.Name,
5044
+ scriptPath = scriptInstance:GetFullName(),
5045
+ scriptClass = scriptInstance.ClassName,
5046
+ line = lineIdx,
5047
+ snippet = string.match(snippet, "^%s*(.-)%s*$") or snippet,
5048
+ description = rule.description,
5049
+ })
5050
+ end
5051
+ end
5052
+ end
5053
+ end
5054
+
5055
+ -- 2. Cek Obfuscation
5056
+ local isObf, obfReason = checkByteObfuscation(source)
5057
+ if isObf then
5058
+ table.insert(threats, {
5059
+ severity = "HIGH",
5060
+ ruleId = "BYTE_OBFUSCATION",
5061
+ name = "Byte / Hex Obfuscation Payload",
5062
+ scriptName = scriptInstance.Name,
5063
+ scriptPath = scriptInstance:GetFullName(),
5064
+ scriptClass = scriptInstance.ClassName,
5065
+ line = 1,
5066
+ snippet = string.sub(source, 1, 60) .. "...",
5067
+ description = obfReason or "Payload kode disamarkan menggunakan escape bytes.",
5068
+ })
5069
+ end
5070
+
5071
+ -- 3. Cek Off-screen Code Hiding
5072
+ local isHidden, hideReason = checkOffscreenHiding(source)
5073
+ if isHidden then
5074
+ table.insert(threats, {
5075
+ severity = "HIGH",
5076
+ ruleId = "OFFSCREEN_HIDING",
5077
+ name = "Off-Screen Hidden Code",
5078
+ scriptName = scriptInstance.Name,
5079
+ scriptPath = scriptInstance:GetFullName(),
5080
+ scriptClass = scriptInstance.ClassName,
5081
+ line = 1,
5082
+ snippet = "[Whitespace Padding Terdeteksi]",
5083
+ description = hideReason or "Kode disembunyikan menggunakan whitespace ekstrem.",
5084
+ })
5085
+ end
5086
+
5087
+ return threats
5088
+ end
5089
+
5090
+ -- ============================================================================
5091
+ -- PUBLIC API
5092
+ -- ============================================================================
5093
+
5094
+ -- Memindai Instance secara rekursif
5095
+ function SecurityScanner.scanInstance(rootInstance: Instance, deepScan: boolean?)
5096
+ local shouldDeepScan = (deepScan ~= false)
5097
+ local allThreats = {}
5098
+ local stats = {
5099
+ totalInstances = 1,
5100
+ models = 0,
5101
+ parts = 0,
5102
+ meshParts = 0,
5103
+ textures = 0,
5104
+ scripts = {
5105
+ total = 0,
5106
+ server = 0,
5107
+ localScript = 0,
5108
+ module = 0,
5109
+ },
5110
+ }
5111
+
5112
+ local function inspectItem(item: Instance)
5113
+ stats.totalInstances = stats.totalInstances + 1
5114
+
5115
+ if item:IsA("Model") then
5116
+ stats.models = stats.models + 1
5117
+ elseif item:IsA("MeshPart") then
5118
+ stats.meshParts = stats.meshParts + 1
5119
+ stats.parts = stats.parts + 1
5120
+ elseif item:IsA("BasePart") then
5121
+ stats.parts = stats.parts + 1
5122
+ elseif item:IsA("Texture") or item:IsA("Decal") or item:IsA("SurfaceAppearance") then
5123
+ stats.textures = stats.textures + 1
5124
+ end
5125
+
5126
+ if item:IsA("LuaSourceContainer") then
5127
+ stats.scripts.total = stats.scripts.total + 1
5128
+ if item:IsA("Script") then
5129
+ stats.scripts.server = stats.scripts.server + 1
5130
+ elseif item:IsA("LocalScript") then
5131
+ stats.scripts.localScript = stats.scripts.localScript + 1
5132
+ elseif item:IsA("ModuleScript") then
5133
+ stats.scripts.module = stats.scripts.module + 1
5134
+ end
5135
+
5136
+ -- Cek nama mencurigakan
5137
+ if checkSuspiciousName(item.Name) then
5138
+ table.insert(allThreats, {
5139
+ severity = "HIGH",
5140
+ ruleId = "SUSPICIOUS_SCRIPT_NAME",
5141
+ name = "Known Malicious Script Name Pattern",
5142
+ scriptName = item.Name,
5143
+ scriptPath = item:GetFullName(),
5144
+ scriptClass = item.ClassName,
5145
+ line = 0,
5146
+ snippet = item.Name,
5147
+ description = string.format("Nama skrip '%s' cocok dengan pola nama virus/backdoor klasik.", item.Name),
5148
+ })
5149
+ end
5150
+
5151
+ -- Deep scan isi source code
5152
+ if shouldDeepScan then
5153
+ local success, source = pcall(function()
5154
+ return (item :: any).Source
5155
+ end)
5156
+ if success and typeof(source) == "string" and #source > 0 then
5157
+ local scriptThreats = scanScriptSource(item, source)
5158
+ for _, t in ipairs(scriptThreats) do
5159
+ table.insert(allThreats, t)
5160
+ end
5161
+ end
5162
+ end
5163
+ end
5164
+ end
5165
+
5166
+ inspectItem(rootInstance)
5167
+ for _, desc in ipairs(rootInstance:GetDescendants()) do
5168
+ inspectItem(desc)
5169
+ end
5170
+
5171
+ -- Hitung Level Risiko
5172
+ local riskLevel = "SAFE"
5173
+ local highCount = 0
5174
+ local medCount = 0
5175
+
5176
+ for _, t in ipairs(allThreats) do
5177
+ if t.severity == "HIGH" then
5178
+ highCount = highCount + 1
5179
+ elseif t.severity == "MEDIUM" then
5180
+ medCount = medCount + 1
5181
+ end
5182
+ end
5183
+
5184
+ if highCount > 0 then
5185
+ riskLevel = "DANGEROUS"
5186
+ elseif medCount > 0 or stats.scripts.total > 0 then
5187
+ riskLevel = "WARNING"
5188
+ end
5189
+
5190
+ return {
5191
+ riskLevel = riskLevel,
5192
+ threats = allThreats,
5193
+ stats = stats,
5194
+ highThreats = highCount,
5195
+ mediumThreats = medCount,
5196
+ }
5197
+ end
5198
+
5199
+ -- Menghapus seluruh skrip dari sebuah instance (Sanitasi Total)
5200
+ function SecurityScanner.stripScripts(rootInstance: Instance): { any }
5201
+ local removed = {}
5202
+
5203
+ for _, desc in ipairs(rootInstance:GetDescendants()) do
5204
+ if desc:IsA("LuaSourceContainer") then
5205
+ table.insert(removed, {
5206
+ name = desc.Name,
5207
+ className = desc.ClassName,
5208
+ path = desc:GetFullName(),
5209
+ })
5210
+ pcall(function()
5211
+ desc:Destroy()
5212
+ end)
5213
+ end
5214
+ end
5215
+
5216
+ if rootInstance:IsA("LuaSourceContainer") then
5217
+ table.insert(removed, {
5218
+ name = rootInstance.Name,
5219
+ className = rootInstance.ClassName,
5220
+ path = rootInstance:GetFullName(),
5221
+ })
5222
+ pcall(function()
5223
+ rootInstance:Destroy()
5224
+ end)
5225
+ end
5226
+
5227
+ return removed
5228
+ end
5229
+
5230
+ return SecurityScanner
5231
+
1627
5232
  ]]></ProtectedString>
1628
5233
  </Properties>
1629
5234
  </Item>
@@ -1651,6 +5256,10 @@ end
1651
5256
  local currentVal = targetInstance[prop]
1652
5257
  if typeof(currentVal) == "Vector3" and type(val) == "table" and #val == 3 then
1653
5258
  targetInstance[prop] = Vector3.new(val[1], val[2], val[3])
5259
+ elseif typeof(currentVal) == "Vector2" and type(val) == "table" and #val >= 2 then
5260
+ targetInstance[prop] = Vector2.new(val[1], val[2])
5261
+ elseif typeof(currentVal) == "UDim" and type(val) == "table" and #val >= 2 then
5262
+ targetInstance[prop] = UDim.new(val[1], val[2])
1654
5263
  elseif typeof(currentVal) == "Color3" and type(val) == "table" and #val == 3 then
1655
5264
  local r, g, b = val[1], val[2], val[3]
1656
5265
  if r > 1 or g > 1 or b > 1 then
@@ -1660,8 +5269,87 @@ end
1660
5269
  end
1661
5270
  elseif typeof(currentVal) == "UDim2" and type(val) == "table" and #val == 4 then
1662
5271
  targetInstance[prop] = UDim2.new(val[1], val[2], val[3], val[4])
5272
+ elseif typeof(currentVal) == "NumberRange" and type(val) == "table" then
5273
+ targetInstance[prop] = NumberRange.new(val[1], val[2] or val[1])
5274
+ elseif typeof(currentVal) == "Rect" and type(val) == "table" and #val >= 4 then
5275
+ targetInstance[prop] = Rect.new(val[1], val[2], val[3], val[4])
5276
+ elseif typeof(currentVal) == "BrickColor" and type(val) == "string" then
5277
+ targetInstance[prop] = BrickColor.new(val)
1663
5278
  elseif typeof(currentVal) == "CFrame" and type(val) == "table" then
1664
5279
  targetInstance[prop] = CFrame.new(unpack(val))
5280
+ elseif typeof(currentVal) == "ColorSequence" or (type(val) == "table" and val.t == "ColorSequence") then
5281
+ local kps = {}
5282
+ local rawList = (type(val) == "table" and val.t == "ColorSequence") and val.v or val
5283
+ if type(rawList) == "table" then
5284
+ for _, kp in ipairs(rawList) do
5285
+ local t = kp[1] or 0
5286
+ local col = kp[2]
5287
+ local c3
5288
+ if typeof(col) == "Color3" then
5289
+ c3 = col
5290
+ elseif type(col) == "table" and col.t == "Color3" then
5291
+ c3 = Color3.new(col.v[1], col.v[2], col.v[3])
5292
+ elseif type(col) == "table" and #col >= 3 then
5293
+ c3 = (col[1] > 1 or col[2] > 1 or col[3] > 1) and Color3.fromRGB(col[1], col[2], col[3]) or Color3.new(col[1], col[2], col[3])
5294
+ else
5295
+ c3 = Color3.new(1, 1, 1)
5296
+ end
5297
+ table.insert(kps, ColorSequenceKeypoint.new(math.clamp(t, 0, 1), c3))
5298
+ end
5299
+ end
5300
+ if #kps >= 2 then
5301
+ table.sort(kps, function(a, b) return a.Time < b.Time end)
5302
+ -- Pastikan keypoint pertama t=0 dan terakhir t=1
5303
+ if kps[1].Time ~= 0 then
5304
+ kps[1] = ColorSequenceKeypoint.new(0, kps[1].Value)
5305
+ end
5306
+ if kps[#kps].Time ~= 1 then
5307
+ kps[#kps] = ColorSequenceKeypoint.new(1, kps[#kps].Value)
5308
+ end
5309
+ -- Pastikan waktu meningkat secara monoton murni
5310
+ for i = 2, #kps do
5311
+ if kps[i].Time <= kps[i - 1].Time then
5312
+ local newT = math.min(1.0, kps[i - 1].Time + 0.001)
5313
+ kps[i] = ColorSequenceKeypoint.new(newT, kps[i].Value)
5314
+ end
5315
+ end
5316
+ targetInstance[prop] = ColorSequence.new(kps)
5317
+ elseif #kps == 1 then
5318
+ targetInstance[prop] = ColorSequence.new(kps[1].Value)
5319
+ end
5320
+ elseif typeof(currentVal) == "NumberSequence" or (type(val) == "table" and val.t == "NumberSequence") then
5321
+ local kps = {}
5322
+ local rawList = (type(val) == "table" and val.t == "NumberSequence") and val.v or val
5323
+ if type(rawList) == "table" then
5324
+ for _, kp in ipairs(rawList) do
5325
+ if type(kp) == "table" then
5326
+ local t = kp[1] or 0
5327
+ local v = kp[2] or 0
5328
+ local env = kp[3] or 0
5329
+ table.insert(kps, NumberSequenceKeypoint.new(math.clamp(t, 0, 1), v, env))
5330
+ end
5331
+ end
5332
+ end
5333
+ if #kps >= 2 then
5334
+ table.sort(kps, function(a, b) return a.Time < b.Time end)
5335
+ -- Pastikan keypoint pertama t=0 dan terakhir t=1
5336
+ if kps[1].Time ~= 0 then
5337
+ kps[1] = NumberSequenceKeypoint.new(0, kps[1].Value, kps[1].Envelope)
5338
+ end
5339
+ if kps[#kps].Time ~= 1 then
5340
+ kps[#kps] = NumberSequenceKeypoint.new(1, kps[#kps].Value, kps[#kps].Envelope)
5341
+ end
5342
+ -- Pastikan waktu meningkat secara monoton murni
5343
+ for i = 2, #kps do
5344
+ if kps[i].Time <= kps[i - 1].Time then
5345
+ local newT = math.min(1.0, kps[i - 1].Time + 0.001)
5346
+ kps[i] = NumberSequenceKeypoint.new(newT, kps[i].Value, kps[i].Envelope)
5347
+ end
5348
+ end
5349
+ targetInstance[prop] = NumberSequence.new(kps)
5350
+ elseif #kps == 1 then
5351
+ targetInstance[prop] = NumberSequence.new(kps[1].Value)
5352
+ end
1665
5353
  elseif typeof(currentVal) == "EnumItem" and type(val) == "string" then
1666
5354
  local enumType = tostring(currentVal.EnumType)
1667
5355
  local successEnum, enumValue = pcall(function() return Enum[enumType][val] end)
@@ -1710,6 +5398,82 @@ end
1710
5398
  end
1711
5399
  return { status = "success", result = "Berhasil mengubah properti: " .. table.concat(applied, ", ") }
1712
5400
  end
5401
+ ]]></ProtectedString>
5402
+ </Properties>
5403
+ </Item>
5404
+ <Item class="ModuleScript" referent="RBX_solve_limb_ik">
5405
+ <Properties>
5406
+ <BinaryString name="AttributesSerialize"></BinaryString>
5407
+ <SecurityCapabilities name="Capabilities">0</SecurityCapabilities>
5408
+ <bool name="DefinesCapabilities">false</bool>
5409
+ <bool name="Disabled">false</bool>
5410
+ <Content name="LinkedSource"><null></null></Content>
5411
+ <string name="Name">solve_limb_ik</string>
5412
+ <int64 name="SourceAssetId">-1</int64>
5413
+ <BinaryString name="Tags"></BinaryString>
5414
+ <ProtectedString name="Source"><![CDATA[--!strict
5415
+ -- ══════════════════════════════════════════════════════════════════════
5416
+ -- Skill Module: solve_limb_ik
5417
+ -- Deskripsi: Semantic Tool untuk AI Agent menghitung orientasi sendi
5418
+ -- lengan / kaki menuju koordinat sasaran 3D secara analitis O(1).
5419
+ -- ══════════════════════════════════════════════════════════════════════
5420
+
5421
+ local HttpService = game:GetService("HttpService")
5422
+ local kinematicsMod = script.Parent:WaitForChild("nv_kinematics")
5423
+ local Kinematics = require(kinematicsMod)
5424
+
5425
+ return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
5426
+ if not targetInstance then
5427
+ return { status = "error", error = "Target karakter rig untuk solve_limb_ik tidak ditemukan: " .. tostring(targetPath) }
5428
+ end
5429
+
5430
+ local rigInfo = Kinematics.inspectRig(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any)
5431
+ if not rigInfo then
5432
+ return { status = "error", error = "Target bukan model karakter rig (R6/R15) yang valid: " .. targetInstance:GetFullName() }
5433
+ end
5434
+
5435
+ local payload: { [string]: any } = {}
5436
+ pcall(function()
5437
+ if type(data) == "table" then payload = data
5438
+ else payload = HttpService:JSONDecode(tostring(data)) end
5439
+ end)
5440
+
5441
+ local limb = tostring(payload.limb or "RightArm")
5442
+ local goalArr = payload.goal
5443
+ if not goalArr or type(goalArr) ~= "table" or #goalArr < 3 then
5444
+ return { status = "error", error = "Parameter 'goal' [X, Y, Z] wajib diisi." }
5445
+ end
5446
+
5447
+ local goalPos = Vector3.new(goalArr[1], goalArr[2], goalArr[3])
5448
+ local polePos: Vector3? = nil
5449
+ if payload.pole and type(payload.pole) == "table" and #payload.pole >= 3 then
5450
+ polePos = Vector3.new(payload.pole[1], payload.pole[2], payload.pole[3])
5451
+ end
5452
+
5453
+ local apply = payload.apply_to_rig ~= false
5454
+ local solved = Kinematics.solveLimb(rigInfo, limb, goalPos, polePos, apply)
5455
+
5456
+ if not solved then
5457
+ return { status = "error", error = "Gagal menyelesaikan IK untuk anggota tubuh: " .. limb }
5458
+ end
5459
+
5460
+ local resultC0: { [string]: any } = {}
5461
+ for mName, cf in pairs(solved) do
5462
+ resultC0[mName] = { t = "CFrame", v = { cf:GetComponents() } }
5463
+ end
5464
+
5465
+ return {
5466
+ status = "success",
5467
+ result = {
5468
+ limb = limb,
5469
+ rigType = rigInfo.isR15 and "R15" or "R6",
5470
+ applied = apply,
5471
+ targetGoal = { goalPos.X, goalPos.Y, goalPos.Z },
5472
+ solvedMotors = resultC0
5473
+ }
5474
+ }
5475
+ end
5476
+
1713
5477
  ]]></ProtectedString>
1714
5478
  </Properties>
1715
5479
  </Item>
@@ -1820,7 +5584,7 @@ function UIManager.init(pluginInstance, studioId)
1820
5584
 
1821
5585
  -- Version
1822
5586
  local versionLabel = Instance.new("TextLabel")
1823
- versionLabel.Text = "v2.2.0"
5587
+ versionLabel.Text = "v2.4.0"
1824
5588
  versionLabel.Font = Enum.Font.Gotham
1825
5589
  versionLabel.TextSize = 11
1826
5590
  versionLabel.TextColor3 = Color3.fromRGB(120, 120, 130)