@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.
- package/README.md +57 -47
- package/agent-plugin/mcp_config.json +8 -0
- package/agent-plugin/plugin.json +6 -0
- package/agent-plugin/rules/AGENTS.md +118 -0
- package/agent-plugin/skills/nvstudio-animation-vfx/SKILL.md +120 -0
- package/agent-plugin/skills/nvstudio-assets-terrain-guide/SKILL.md +27 -0
- package/agent-plugin/skills/nvstudio-gui-standards/SKILL.md +419 -0
- package/agent-plugin/skills/nvstudio-mcp-guide/SKILL.md +83 -0
- package/agent-plugin/skills/nvstudio-rig-engine/SKILL.md +170 -0
- package/agent-plugin/skills/nvstudio-roblox-sync-guide/SKILL.md +16 -0
- package/dist/build-plugin.js +55 -0
- package/dist/index.js +841 -3
- package/dist/luau-validator.js +99 -34
- package/dist/roblox-bridge.js +152 -4
- package/dist/roblox-docs.js +264 -0
- package/dist/vfx-database.js +507 -0
- package/package.json +6 -4
- package/public/index.html +1590 -180
- package/studio-plugin/Main.server.lua +1 -1
- package/studio-plugin/animate_character.lua +36 -0
- package/studio-plugin/apply_vfx.lua +59 -0
- package/studio-plugin/batch_create_instances.lua +128 -0
- package/studio-plugin/batch_set_properties.lua +69 -0
- package/studio-plugin/edit_rig.lua +35 -0
- package/studio-plugin/insert_asset.lua +85 -12
- package/studio-plugin/nv_anim_dispatcher.lua +239 -0
- package/studio-plugin/nv_auto_rigger.lua +392 -0
- package/studio-plugin/nv_curves.lua +260 -0
- package/studio-plugin/nv_joint_math.lua +138 -0
- package/studio-plugin/nv_kinematics.lua +248 -0
- package/studio-plugin/nv_motion_generator.lua +279 -0
- package/studio-plugin/nv_rig_dispatcher.lua +319 -0
- package/studio-plugin/nv_vfx_serializer.lua +363 -0
- package/studio-plugin/nvstudio_mcp.rbxmx +3824 -60
- package/studio-plugin/preview_asset.lua +99 -0
- package/studio-plugin/scan_security.lua +81 -0
- package/studio-plugin/scan_vfx.lua +85 -0
- package/studio-plugin/scrub_timeline.lua +122 -0
- package/studio-plugin/security_scanner.lua +346 -0
- package/studio-plugin/set_properties.lua +83 -0
- package/studio-plugin/solve_limb_ik.lua +63 -0
- package/studio-plugin/ui_manager.lua +1 -1
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- ============================================================================
|
|
3
|
+
-- Module: security_scanner
|
|
4
|
+
-- Deskripsi: Engine Heuristik Pemindaian Keamanan & Backdoor Detection
|
|
5
|
+
-- Standar: Business-Grade, Zero-Execution Isolation, Comprehensive Threat Rules
|
|
6
|
+
-- ============================================================================
|
|
7
|
+
|
|
8
|
+
local SecurityScanner = {}
|
|
9
|
+
|
|
10
|
+
-- Daftar nama skrip yang sering digunakan oleh virus/backdoor klasik Roblox
|
|
11
|
+
local SUSPICIOUS_SCRIPT_NAMES = {
|
|
12
|
+
"[Vv]accine",
|
|
13
|
+
"[Aa]nti[Ll]ag",
|
|
14
|
+
"[Ff]ix[Ll]ag",
|
|
15
|
+
"[Ii]nfection",
|
|
16
|
+
"[Ff]ire",
|
|
17
|
+
"[Cc]lean",
|
|
18
|
+
"[Vv]irus",
|
|
19
|
+
"4[Dd]",
|
|
20
|
+
"4[Dd]_[Pp]lugin",
|
|
21
|
+
"[Ss]pread",
|
|
22
|
+
"[Ll]ag[Ff]ixer",
|
|
23
|
+
"[Dd]up",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
-- Aturan Pola Heuristik Kode Berbahaya
|
|
27
|
+
local THREAT_PATTERNS = {
|
|
28
|
+
{
|
|
29
|
+
id = "BACKDOOR_REQUIRE",
|
|
30
|
+
pattern = "require%s*%(%s*%d+%s*%)",
|
|
31
|
+
severity = "HIGH",
|
|
32
|
+
name = "Backdoor Numeric Require",
|
|
33
|
+
description = "Memanggil modul eksternal pihak ketiga via Asset ID numerik (indikasi backdoor/remote loader).",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id = "LOADSTRING_EXEC",
|
|
37
|
+
pattern = "loadstring%s*%(",
|
|
38
|
+
severity = "HIGH",
|
|
39
|
+
name = "Dynamic Code Execution (loadstring)",
|
|
40
|
+
description = "Mengeksekusi string kode dinamis yang tidak dapat diaudit.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id = "GETFENV_SETFENV",
|
|
44
|
+
pattern = "[gs]etfenv%s*%(",
|
|
45
|
+
severity = "HIGH",
|
|
46
|
+
name = "Environment Manipulation (getfenv/setfenv)",
|
|
47
|
+
description = "Memanipulasi environment global Luau untuk mengelabui analisis statis.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id = "DISCORD_WEBHOOK",
|
|
51
|
+
pattern = "discord[a-z]*%.com/api/webhooks",
|
|
52
|
+
severity = "HIGH",
|
|
53
|
+
name = "Discord Webhook Exfiltration",
|
|
54
|
+
description = "Mencoba mengirimkan data game atau informasi pemain ke webhook eksternal Discord.",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id = "IP_LOGGER",
|
|
58
|
+
pattern = "webhook%.site|grabify%.link|iplogger|blasze",
|
|
59
|
+
severity = "HIGH",
|
|
60
|
+
name = "External IP/Data Logger",
|
|
61
|
+
description = "Menghubungi domain pencatat IP atau data capture pihak ketiga.",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id = "FORCED_PURCHASE",
|
|
65
|
+
pattern = "MarketplaceService%s*:%s*Prompt[A-Za-z]*Purchase",
|
|
66
|
+
severity = "MEDIUM",
|
|
67
|
+
name = "Forced Robux/Product Purchase",
|
|
68
|
+
description = "Memicu dialog pembelian paksa Gamepass atau Developer Product.",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id = "FORCED_TELEPORT",
|
|
72
|
+
pattern = "TeleportService%s*:%s*Teleport",
|
|
73
|
+
severity = "MEDIUM",
|
|
74
|
+
name = "Forced Game Teleportation",
|
|
75
|
+
description = "Memindahkan pemain secara paksa ke game/place lain.",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id = "CHAR_TABLE_OBFUSCATION",
|
|
79
|
+
pattern = "string%.char%s*%(%s*%d+%s*,%s*%d+%s*,%s*%d+",
|
|
80
|
+
severity = "HIGH",
|
|
81
|
+
name = "Obfuscated string.char Array",
|
|
82
|
+
description = "Menyusun kode rahasia tersembunyi menggunakan deretan kode karakter ASCII.",
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
-- Helper: Pengecekan nama script mencurigakan
|
|
87
|
+
local function checkSuspiciousName(name: string): boolean
|
|
88
|
+
for _, pattern in ipairs(SUSPICIOUS_SCRIPT_NAMES) do
|
|
89
|
+
if string.find(name, pattern) then
|
|
90
|
+
return true
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
return false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
-- Helper: Deteksi obfuscation byte escape (\108\111\97\100) atau hex (\x6c\x6f)
|
|
97
|
+
local function checkByteObfuscation(source: string): (boolean, string?)
|
|
98
|
+
local byteCount = 0
|
|
99
|
+
for _ in string.gmatch(source, "\\[0-9][0-9][0-9]") do
|
|
100
|
+
byteCount = byteCount + 1
|
|
101
|
+
if byteCount >= 4 then
|
|
102
|
+
return true, "Terdeteksi deretan escape byte ASCII (\\ddd) berulang."
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
local hexCount = 0
|
|
107
|
+
for _ in string.gmatch(source, "\\x[0-9a-fA-F][0-9a-fA-F]") do
|
|
108
|
+
hexCount = hexCount + 1
|
|
109
|
+
if hexCount >= 4 then
|
|
110
|
+
return true, "Terdeteksi deretan escape hex (\\xHH) berulang."
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
return false, nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
-- Helper: Deteksi kode yang sengaja disembunyikan dengan spasi/baris kosong berlebih
|
|
118
|
+
local function checkOffscreenHiding(source: string): (boolean, string?)
|
|
119
|
+
-- Cek spasi horizontal masif (> 80 spasi berturut-turut)
|
|
120
|
+
if string.find(source, " " .. string.rep(" ", 80)) then
|
|
121
|
+
return true, "Kode sengaja didorong ke kanan dengan lebih dari 80 spasi agar tersembunyi dari layar Studio."
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
-- Cek baris kosong masif di awal skrip (> 15 baris kosong)
|
|
125
|
+
local leadingNewlines = 0
|
|
126
|
+
for line in string.gmatch(source, "([^\r\n]*)\r?\n") do
|
|
127
|
+
if string.gsub(line, "%s+", "") == "" then
|
|
128
|
+
leadingNewlines = leadingNewlines + 1
|
|
129
|
+
if leadingNewlines >= 15 then
|
|
130
|
+
return true, "Terdapat lebih dari 15 baris kosong di awal skrip untuk menyembunyikan payload di baris bawah."
|
|
131
|
+
end
|
|
132
|
+
else
|
|
133
|
+
break
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
return false, nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
-- Analisis Source Code dari sebuah Script
|
|
141
|
+
local function scanScriptSource(scriptInstance: Instance, source: string): { any }
|
|
142
|
+
local threats = {}
|
|
143
|
+
local lines = string.split(source, "\n")
|
|
144
|
+
|
|
145
|
+
-- 1. Scan pola heuristik baris per baris
|
|
146
|
+
for lineIdx, line in ipairs(lines) do
|
|
147
|
+
-- Skip baris komentar murni
|
|
148
|
+
local trimmed = string.match(line, "^%s*(.-)%s*$") or ""
|
|
149
|
+
if not string.sub(trimmed, 1, 2) == "--" then
|
|
150
|
+
for _, rule in ipairs(THREAT_PATTERNS) do
|
|
151
|
+
local s, e = string.find(line, rule.pattern)
|
|
152
|
+
if s then
|
|
153
|
+
local snippet = string.sub(line, math.max(1, s - 30), math.min(#line, e + 30))
|
|
154
|
+
table.insert(threats, {
|
|
155
|
+
severity = rule.severity,
|
|
156
|
+
ruleId = rule.id,
|
|
157
|
+
name = rule.name,
|
|
158
|
+
scriptName = scriptInstance.Name,
|
|
159
|
+
scriptPath = scriptInstance:GetFullName(),
|
|
160
|
+
scriptClass = scriptInstance.ClassName,
|
|
161
|
+
line = lineIdx,
|
|
162
|
+
snippet = string.match(snippet, "^%s*(.-)%s*$") or snippet,
|
|
163
|
+
description = rule.description,
|
|
164
|
+
})
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
-- 2. Cek Obfuscation
|
|
171
|
+
local isObf, obfReason = checkByteObfuscation(source)
|
|
172
|
+
if isObf then
|
|
173
|
+
table.insert(threats, {
|
|
174
|
+
severity = "HIGH",
|
|
175
|
+
ruleId = "BYTE_OBFUSCATION",
|
|
176
|
+
name = "Byte / Hex Obfuscation Payload",
|
|
177
|
+
scriptName = scriptInstance.Name,
|
|
178
|
+
scriptPath = scriptInstance:GetFullName(),
|
|
179
|
+
scriptClass = scriptInstance.ClassName,
|
|
180
|
+
line = 1,
|
|
181
|
+
snippet = string.sub(source, 1, 60) .. "...",
|
|
182
|
+
description = obfReason or "Payload kode disamarkan menggunakan escape bytes.",
|
|
183
|
+
})
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
-- 3. Cek Off-screen Code Hiding
|
|
187
|
+
local isHidden, hideReason = checkOffscreenHiding(source)
|
|
188
|
+
if isHidden then
|
|
189
|
+
table.insert(threats, {
|
|
190
|
+
severity = "HIGH",
|
|
191
|
+
ruleId = "OFFSCREEN_HIDING",
|
|
192
|
+
name = "Off-Screen Hidden Code",
|
|
193
|
+
scriptName = scriptInstance.Name,
|
|
194
|
+
scriptPath = scriptInstance:GetFullName(),
|
|
195
|
+
scriptClass = scriptInstance.ClassName,
|
|
196
|
+
line = 1,
|
|
197
|
+
snippet = "[Whitespace Padding Terdeteksi]",
|
|
198
|
+
description = hideReason or "Kode disembunyikan menggunakan whitespace ekstrem.",
|
|
199
|
+
})
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
return threats
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
-- ============================================================================
|
|
206
|
+
-- PUBLIC API
|
|
207
|
+
-- ============================================================================
|
|
208
|
+
|
|
209
|
+
-- Memindai Instance secara rekursif
|
|
210
|
+
function SecurityScanner.scanInstance(rootInstance: Instance, deepScan: boolean?)
|
|
211
|
+
local shouldDeepScan = (deepScan ~= false)
|
|
212
|
+
local allThreats = {}
|
|
213
|
+
local stats = {
|
|
214
|
+
totalInstances = 1,
|
|
215
|
+
models = 0,
|
|
216
|
+
parts = 0,
|
|
217
|
+
meshParts = 0,
|
|
218
|
+
textures = 0,
|
|
219
|
+
scripts = {
|
|
220
|
+
total = 0,
|
|
221
|
+
server = 0,
|
|
222
|
+
localScript = 0,
|
|
223
|
+
module = 0,
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
local function inspectItem(item: Instance)
|
|
228
|
+
stats.totalInstances = stats.totalInstances + 1
|
|
229
|
+
|
|
230
|
+
if item:IsA("Model") then
|
|
231
|
+
stats.models = stats.models + 1
|
|
232
|
+
elseif item:IsA("MeshPart") then
|
|
233
|
+
stats.meshParts = stats.meshParts + 1
|
|
234
|
+
stats.parts = stats.parts + 1
|
|
235
|
+
elseif item:IsA("BasePart") then
|
|
236
|
+
stats.parts = stats.parts + 1
|
|
237
|
+
elseif item:IsA("Texture") or item:IsA("Decal") or item:IsA("SurfaceAppearance") then
|
|
238
|
+
stats.textures = stats.textures + 1
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
if item:IsA("LuaSourceContainer") then
|
|
242
|
+
stats.scripts.total = stats.scripts.total + 1
|
|
243
|
+
if item:IsA("Script") then
|
|
244
|
+
stats.scripts.server = stats.scripts.server + 1
|
|
245
|
+
elseif item:IsA("LocalScript") then
|
|
246
|
+
stats.scripts.localScript = stats.scripts.localScript + 1
|
|
247
|
+
elseif item:IsA("ModuleScript") then
|
|
248
|
+
stats.scripts.module = stats.scripts.module + 1
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
-- Cek nama mencurigakan
|
|
252
|
+
if checkSuspiciousName(item.Name) then
|
|
253
|
+
table.insert(allThreats, {
|
|
254
|
+
severity = "HIGH",
|
|
255
|
+
ruleId = "SUSPICIOUS_SCRIPT_NAME",
|
|
256
|
+
name = "Known Malicious Script Name Pattern",
|
|
257
|
+
scriptName = item.Name,
|
|
258
|
+
scriptPath = item:GetFullName(),
|
|
259
|
+
scriptClass = item.ClassName,
|
|
260
|
+
line = 0,
|
|
261
|
+
snippet = item.Name,
|
|
262
|
+
description = string.format("Nama skrip '%s' cocok dengan pola nama virus/backdoor klasik.", item.Name),
|
|
263
|
+
})
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
-- Deep scan isi source code
|
|
267
|
+
if shouldDeepScan then
|
|
268
|
+
local success, source = pcall(function()
|
|
269
|
+
return (item :: any).Source
|
|
270
|
+
end)
|
|
271
|
+
if success and typeof(source) == "string" and #source > 0 then
|
|
272
|
+
local scriptThreats = scanScriptSource(item, source)
|
|
273
|
+
for _, t in ipairs(scriptThreats) do
|
|
274
|
+
table.insert(allThreats, t)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
inspectItem(rootInstance)
|
|
282
|
+
for _, desc in ipairs(rootInstance:GetDescendants()) do
|
|
283
|
+
inspectItem(desc)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
-- Hitung Level Risiko
|
|
287
|
+
local riskLevel = "SAFE"
|
|
288
|
+
local highCount = 0
|
|
289
|
+
local medCount = 0
|
|
290
|
+
|
|
291
|
+
for _, t in ipairs(allThreats) do
|
|
292
|
+
if t.severity == "HIGH" then
|
|
293
|
+
highCount = highCount + 1
|
|
294
|
+
elseif t.severity == "MEDIUM" then
|
|
295
|
+
medCount = medCount + 1
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
if highCount > 0 then
|
|
300
|
+
riskLevel = "DANGEROUS"
|
|
301
|
+
elseif medCount > 0 or stats.scripts.total > 0 then
|
|
302
|
+
riskLevel = "WARNING"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
riskLevel = riskLevel,
|
|
307
|
+
threats = allThreats,
|
|
308
|
+
stats = stats,
|
|
309
|
+
highThreats = highCount,
|
|
310
|
+
mediumThreats = medCount,
|
|
311
|
+
}
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
-- Menghapus seluruh skrip dari sebuah instance (Sanitasi Total)
|
|
315
|
+
function SecurityScanner.stripScripts(rootInstance: Instance): { any }
|
|
316
|
+
local removed = {}
|
|
317
|
+
|
|
318
|
+
for _, desc in ipairs(rootInstance:GetDescendants()) do
|
|
319
|
+
if desc:IsA("LuaSourceContainer") then
|
|
320
|
+
table.insert(removed, {
|
|
321
|
+
name = desc.Name,
|
|
322
|
+
className = desc.ClassName,
|
|
323
|
+
path = desc:GetFullName(),
|
|
324
|
+
})
|
|
325
|
+
pcall(function()
|
|
326
|
+
desc:Destroy()
|
|
327
|
+
end)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
if rootInstance:IsA("LuaSourceContainer") then
|
|
332
|
+
table.insert(removed, {
|
|
333
|
+
name = rootInstance.Name,
|
|
334
|
+
className = rootInstance.ClassName,
|
|
335
|
+
path = rootInstance:GetFullName(),
|
|
336
|
+
})
|
|
337
|
+
pcall(function()
|
|
338
|
+
rootInstance:Destroy()
|
|
339
|
+
end)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
return removed
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
return SecurityScanner
|
|
346
|
+
|
|
@@ -12,6 +12,10 @@ return function(targetInstance, data, ctx, targetPath)
|
|
|
12
12
|
local currentVal = targetInstance[prop]
|
|
13
13
|
if typeof(currentVal) == "Vector3" and type(val) == "table" and #val == 3 then
|
|
14
14
|
targetInstance[prop] = Vector3.new(val[1], val[2], val[3])
|
|
15
|
+
elseif typeof(currentVal) == "Vector2" and type(val) == "table" and #val >= 2 then
|
|
16
|
+
targetInstance[prop] = Vector2.new(val[1], val[2])
|
|
17
|
+
elseif typeof(currentVal) == "UDim" and type(val) == "table" and #val >= 2 then
|
|
18
|
+
targetInstance[prop] = UDim.new(val[1], val[2])
|
|
15
19
|
elseif typeof(currentVal) == "Color3" and type(val) == "table" and #val == 3 then
|
|
16
20
|
local r, g, b = val[1], val[2], val[3]
|
|
17
21
|
if r > 1 or g > 1 or b > 1 then
|
|
@@ -21,8 +25,87 @@ return function(targetInstance, data, ctx, targetPath)
|
|
|
21
25
|
end
|
|
22
26
|
elseif typeof(currentVal) == "UDim2" and type(val) == "table" and #val == 4 then
|
|
23
27
|
targetInstance[prop] = UDim2.new(val[1], val[2], val[3], val[4])
|
|
28
|
+
elseif typeof(currentVal) == "NumberRange" and type(val) == "table" then
|
|
29
|
+
targetInstance[prop] = NumberRange.new(val[1], val[2] or val[1])
|
|
30
|
+
elseif typeof(currentVal) == "Rect" and type(val) == "table" and #val >= 4 then
|
|
31
|
+
targetInstance[prop] = Rect.new(val[1], val[2], val[3], val[4])
|
|
32
|
+
elseif typeof(currentVal) == "BrickColor" and type(val) == "string" then
|
|
33
|
+
targetInstance[prop] = BrickColor.new(val)
|
|
24
34
|
elseif typeof(currentVal) == "CFrame" and type(val) == "table" then
|
|
25
35
|
targetInstance[prop] = CFrame.new(unpack(val))
|
|
36
|
+
elseif typeof(currentVal) == "ColorSequence" or (type(val) == "table" and val.t == "ColorSequence") then
|
|
37
|
+
local kps = {}
|
|
38
|
+
local rawList = (type(val) == "table" and val.t == "ColorSequence") and val.v or val
|
|
39
|
+
if type(rawList) == "table" then
|
|
40
|
+
for _, kp in ipairs(rawList) do
|
|
41
|
+
local t = kp[1] or 0
|
|
42
|
+
local col = kp[2]
|
|
43
|
+
local c3
|
|
44
|
+
if typeof(col) == "Color3" then
|
|
45
|
+
c3 = col
|
|
46
|
+
elseif type(col) == "table" and col.t == "Color3" then
|
|
47
|
+
c3 = Color3.new(col.v[1], col.v[2], col.v[3])
|
|
48
|
+
elseif type(col) == "table" and #col >= 3 then
|
|
49
|
+
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])
|
|
50
|
+
else
|
|
51
|
+
c3 = Color3.new(1, 1, 1)
|
|
52
|
+
end
|
|
53
|
+
table.insert(kps, ColorSequenceKeypoint.new(math.clamp(t, 0, 1), c3))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
if #kps >= 2 then
|
|
57
|
+
table.sort(kps, function(a, b) return a.Time < b.Time end)
|
|
58
|
+
-- Pastikan keypoint pertama t=0 dan terakhir t=1
|
|
59
|
+
if kps[1].Time ~= 0 then
|
|
60
|
+
kps[1] = ColorSequenceKeypoint.new(0, kps[1].Value)
|
|
61
|
+
end
|
|
62
|
+
if kps[#kps].Time ~= 1 then
|
|
63
|
+
kps[#kps] = ColorSequenceKeypoint.new(1, kps[#kps].Value)
|
|
64
|
+
end
|
|
65
|
+
-- Pastikan waktu meningkat secara monoton murni
|
|
66
|
+
for i = 2, #kps do
|
|
67
|
+
if kps[i].Time <= kps[i - 1].Time then
|
|
68
|
+
local newT = math.min(1.0, kps[i - 1].Time + 0.001)
|
|
69
|
+
kps[i] = ColorSequenceKeypoint.new(newT, kps[i].Value)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
targetInstance[prop] = ColorSequence.new(kps)
|
|
73
|
+
elseif #kps == 1 then
|
|
74
|
+
targetInstance[prop] = ColorSequence.new(kps[1].Value)
|
|
75
|
+
end
|
|
76
|
+
elseif typeof(currentVal) == "NumberSequence" or (type(val) == "table" and val.t == "NumberSequence") then
|
|
77
|
+
local kps = {}
|
|
78
|
+
local rawList = (type(val) == "table" and val.t == "NumberSequence") and val.v or val
|
|
79
|
+
if type(rawList) == "table" then
|
|
80
|
+
for _, kp in ipairs(rawList) do
|
|
81
|
+
if type(kp) == "table" then
|
|
82
|
+
local t = kp[1] or 0
|
|
83
|
+
local v = kp[2] or 0
|
|
84
|
+
local env = kp[3] or 0
|
|
85
|
+
table.insert(kps, NumberSequenceKeypoint.new(math.clamp(t, 0, 1), v, env))
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
if #kps >= 2 then
|
|
90
|
+
table.sort(kps, function(a, b) return a.Time < b.Time end)
|
|
91
|
+
-- Pastikan keypoint pertama t=0 dan terakhir t=1
|
|
92
|
+
if kps[1].Time ~= 0 then
|
|
93
|
+
kps[1] = NumberSequenceKeypoint.new(0, kps[1].Value, kps[1].Envelope)
|
|
94
|
+
end
|
|
95
|
+
if kps[#kps].Time ~= 1 then
|
|
96
|
+
kps[#kps] = NumberSequenceKeypoint.new(1, kps[#kps].Value, kps[#kps].Envelope)
|
|
97
|
+
end
|
|
98
|
+
-- Pastikan waktu meningkat secara monoton murni
|
|
99
|
+
for i = 2, #kps do
|
|
100
|
+
if kps[i].Time <= kps[i - 1].Time then
|
|
101
|
+
local newT = math.min(1.0, kps[i - 1].Time + 0.001)
|
|
102
|
+
kps[i] = NumberSequenceKeypoint.new(newT, kps[i].Value, kps[i].Envelope)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
targetInstance[prop] = NumberSequence.new(kps)
|
|
106
|
+
elseif #kps == 1 then
|
|
107
|
+
targetInstance[prop] = NumberSequence.new(kps[1].Value)
|
|
108
|
+
end
|
|
26
109
|
elseif typeof(currentVal) == "EnumItem" and type(val) == "string" then
|
|
27
110
|
local enumType = tostring(currentVal.EnumType)
|
|
28
111
|
local successEnum, enumValue = pcall(function() return Enum[enumType][val] end)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
3
|
+
-- Skill Module: solve_limb_ik
|
|
4
|
+
-- Deskripsi: Semantic Tool untuk AI Agent menghitung orientasi sendi
|
|
5
|
+
-- lengan / kaki menuju koordinat sasaran 3D secara analitis O(1).
|
|
6
|
+
-- ══════════════════════════════════════════════════════════════════════
|
|
7
|
+
|
|
8
|
+
local HttpService = game:GetService("HttpService")
|
|
9
|
+
local kinematicsMod = script.Parent:WaitForChild("nv_kinematics")
|
|
10
|
+
local Kinematics = require(kinematicsMod)
|
|
11
|
+
|
|
12
|
+
return function(targetInstance: Instance?, data: any, ctx: any, targetPath: string?)
|
|
13
|
+
if not targetInstance then
|
|
14
|
+
return { status = "error", error = "Target karakter rig untuk solve_limb_ik tidak ditemukan: " .. tostring(targetPath) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
local rigInfo = Kinematics.inspectRig(targetInstance:IsA("Model") and targetInstance or targetInstance:FindFirstAncestorOfClass("Model") :: any)
|
|
18
|
+
if not rigInfo then
|
|
19
|
+
return { status = "error", error = "Target bukan model karakter rig (R6/R15) yang valid: " .. targetInstance:GetFullName() }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
local payload: { [string]: any } = {}
|
|
23
|
+
pcall(function()
|
|
24
|
+
if type(data) == "table" then payload = data
|
|
25
|
+
else payload = HttpService:JSONDecode(tostring(data)) end
|
|
26
|
+
end)
|
|
27
|
+
|
|
28
|
+
local limb = tostring(payload.limb or "RightArm")
|
|
29
|
+
local goalArr = payload.goal
|
|
30
|
+
if not goalArr or type(goalArr) ~= "table" or #goalArr < 3 then
|
|
31
|
+
return { status = "error", error = "Parameter 'goal' [X, Y, Z] wajib diisi." }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
local goalPos = Vector3.new(goalArr[1], goalArr[2], goalArr[3])
|
|
35
|
+
local polePos: Vector3? = nil
|
|
36
|
+
if payload.pole and type(payload.pole) == "table" and #payload.pole >= 3 then
|
|
37
|
+
polePos = Vector3.new(payload.pole[1], payload.pole[2], payload.pole[3])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
local apply = payload.apply_to_rig ~= false
|
|
41
|
+
local solved = Kinematics.solveLimb(rigInfo, limb, goalPos, polePos, apply)
|
|
42
|
+
|
|
43
|
+
if not solved then
|
|
44
|
+
return { status = "error", error = "Gagal menyelesaikan IK untuk anggota tubuh: " .. limb }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
local resultC0: { [string]: any } = {}
|
|
48
|
+
for mName, cf in pairs(solved) do
|
|
49
|
+
resultC0[mName] = { t = "CFrame", v = { cf:GetComponents() } }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
status = "success",
|
|
54
|
+
result = {
|
|
55
|
+
limb = limb,
|
|
56
|
+
rigType = rigInfo.isR15 and "R15" or "R6",
|
|
57
|
+
applied = apply,
|
|
58
|
+
targetGoal = { goalPos.X, goalPos.Y, goalPos.Z },
|
|
59
|
+
solvedMotors = resultC0
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
@@ -95,7 +95,7 @@ function UIManager.init(pluginInstance, studioId)
|
|
|
95
95
|
|
|
96
96
|
-- Version
|
|
97
97
|
local versionLabel = Instance.new("TextLabel")
|
|
98
|
-
versionLabel.Text = "v2.
|
|
98
|
+
versionLabel.Text = "v2.4.0"
|
|
99
99
|
versionLabel.Font = Enum.Font.Gotham
|
|
100
100
|
versionLabel.TextSize = 11
|
|
101
101
|
versionLabel.TextColor3 = Color3.fromRGB(120, 120, 130)
|