luau-obfuscator 1.0.0 → 1.0.2
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/.github/workflows/release.yml +56 -56
- package/dist/index.cjs +2186 -95
- package/dist/index.d.cts +34 -2
- package/dist/index.d.ts +34 -2
- package/dist/index.js +2186 -95
- package/generated/grammers.luau +13643 -0
- package/generated/tests.luau +5567 -0
- package/package.json +2 -2
- package/scripts/test.js +24 -8
- package/{scripts/example.luau → smoketest/grammers.luau} +1174 -1174
- package/smoketest/tests.luau +115 -0
- package/src/config.ts +2 -1
- package/src/passes/ConstantArray.ts +90 -89
- package/src/passes/EncryptNumbers.ts +122 -65
- package/src/passes/EncryptStrings.ts +162 -82
- package/src/passes/GlobalMapping.ts +193 -193
- package/src/passes/InsertJunk.ts +184 -184
- package/src/passes/Minify.ts +5 -5
- package/src/passes/NumbersToExpressions.ts +183 -2
- package/src/passes/RenameVariables.ts +2 -9
- package/src/passes/StringsToExpressions.ts +195 -6
- package/src/passes/StripTypes.ts +191 -191
- package/src/passes/Vmify.ts +87 -0
- package/src/passes/WrapInFunction.ts +31 -31
- package/src/passes/nodeFactory.ts +36 -1
- package/src/passes/vmify/chunk.ts +54 -0
- package/src/passes/vmify/compiler.ts +1026 -0
- package/src/passes/vmify/names.ts +72 -0
- package/src/passes/vmify/opcodes.ts +80 -0
- package/src/passes/vmify/registers.ts +63 -0
- package/src/passes/vmify/runtime.ts +334 -0
- package/src/passes/vmify/scope-walk.ts +175 -0
- package/src/passes/vmify/serialize.ts +68 -0
- package/src/passes/walk.ts +141 -43
- package/src/pipeline.ts +5 -6
- package/tsconfig.json +20 -20
- package/tsup.config.ts +9 -9
- package/generated/final.luau +0 -1029
|
@@ -1,83 +1,163 @@
|
|
|
1
|
-
import type { Program, Expression } from "luau-parser"
|
|
2
|
-
import { transformExpressions } from "./walk"
|
|
3
|
-
import {
|
|
4
|
-
identifier,
|
|
5
|
-
localFunctionStatement, functionParam, functionBody, block, localStatement,
|
|
6
|
-
assignmentStatement, numericForStatement, returnStatement, unary,
|
|
7
|
-
} from "./nodeFactory"
|
|
8
|
-
|
|
9
|
-
export interface EncryptStringsOptions {}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
1
|
+
import type { Program, Expression } from "luau-parser"
|
|
2
|
+
import { transformExpressions } from "./walk"
|
|
3
|
+
import {
|
|
4
|
+
identifier, vmNumberLiteral, call, member, index, table, positionalField,
|
|
5
|
+
localFunctionStatement, functionParam, functionBody, block, localStatement,
|
|
6
|
+
assignmentStatement, numericForStatement, returnStatement, unary, binary,
|
|
7
|
+
} from "./nodeFactory"
|
|
8
|
+
|
|
9
|
+
export interface EncryptStringsOptions {}
|
|
10
|
+
|
|
11
|
+
const MIN_KEY_LEN = 4
|
|
12
|
+
const MAX_KEY_LEN = 12
|
|
13
|
+
|
|
14
|
+
function randomInt(min: number, max: number): number {
|
|
15
|
+
return Math.floor(Math.random() * (max - min + 1)) + min
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function randomName(): string {
|
|
19
|
+
return "_" + globalThis.crypto.randomUUID().replace(/-/g, "")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Luau 소스는 UTF-8 바이트 스트림이므로 charCodeAt이 아니라 실제 UTF-8 바이트로 변환. */
|
|
23
|
+
function toUtf8Bytes(value: string): number[] {
|
|
24
|
+
return Array.from(new TextEncoder().encode(value))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** bit32.lrotate(x, disp)와 동일한 32비트 좌회전. disp는 0~31 범위로 정규화. */
|
|
28
|
+
function lrotate32(x: number, disp: number): number {
|
|
29
|
+
const d = ((disp % 32) + 32) % 32
|
|
30
|
+
const xu = x >>> 0
|
|
31
|
+
if (d === 0) return xu
|
|
32
|
+
return ((xu << d) | (xu >>> (32 - d))) >>> 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* i번째(1-indexed) 바이트에 적용할 키스트림 바이트.
|
|
37
|
+
* - 롤링 키 배열에서 위치별로 다른 키 바이트를 뽑고
|
|
38
|
+
* - 좌회전으로 비트를 섞은 뒤
|
|
39
|
+
* - 위치(i)에 종속적인 바이트와 다시 섞는다.
|
|
40
|
+
* 같은 평문 바이트가 반복돼도 위치가 다르면 암호문 바이트가 달라져
|
|
41
|
+
* (반복 키 XOR과 달리) 단순 빈도분석/Kasiski류 공격에 내성을 갖는다.
|
|
42
|
+
*/
|
|
43
|
+
function keystreamByte(keys: number[], mult: number, i: number): number {
|
|
44
|
+
const nKeys = keys.length
|
|
45
|
+
const keyByte = keys[(i - 1) % nKeys]
|
|
46
|
+
const rotated = lrotate32(keyByte, i % 8) & 0xFF
|
|
47
|
+
const posByte = (i * mult) & 0xFF
|
|
48
|
+
return (rotated ^ posByte) & 0xFF
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 숫자 리터럴을 곧이곧대로 노출하지 않고, 산술식/bit32.bxor 호출로 위장한다.
|
|
53
|
+
* "decoder(literal, literal)" 패턴을 그대로 상수 접기(constant fold)하는
|
|
54
|
+
* 단순 정적 분석 스크립트를 무력화하기 위함.
|
|
55
|
+
*/
|
|
56
|
+
function obfuscatedNumber(value: number): Expression {
|
|
57
|
+
const variant = randomInt(0, 2)
|
|
58
|
+
if (variant === 0) {
|
|
59
|
+
const a = randomInt(0, value)
|
|
60
|
+
return binary("+", vmNumberLiteral(a), vmNumberLiteral(value - a))
|
|
61
|
+
}
|
|
62
|
+
if (variant === 1) {
|
|
63
|
+
const a = randomInt(0, 0xFFFFFF)
|
|
64
|
+
return binary("-", vmNumberLiteral(value + a), vmNumberLiteral(a))
|
|
65
|
+
}
|
|
66
|
+
const a = randomInt(0, 0xFFFFFF)
|
|
67
|
+
const b = (value ^ a) >>> 0
|
|
68
|
+
return call(member(identifier("bit32"), "bxor"), [vmNumberLiteral(a), vmNumberLiteral(b)])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 런타임 디코더를 조립:
|
|
73
|
+
* local function <name>(data, keys, mult)
|
|
74
|
+
* local nKeys = #keys
|
|
75
|
+
* local out = {}
|
|
76
|
+
* for i = 1, #data do
|
|
77
|
+
* local keyByte = keys[((i - 1) % nKeys) + 1]
|
|
78
|
+
* local mixed = bit32.band(bit32.lrotate(keyByte, i % 8), 0xFF)
|
|
79
|
+
* local posByte = bit32.band(i * mult, 0xFF)
|
|
80
|
+
* out[i] = string.char(bit32.bxor(data[i], bit32.bxor(mixed, posByte)))
|
|
81
|
+
* end
|
|
82
|
+
* return table.concat(out)
|
|
83
|
+
* end
|
|
84
|
+
*/
|
|
85
|
+
function buildDecoderStatement(name: string) {
|
|
86
|
+
const dataParam = randomName()
|
|
87
|
+
const keysParam = randomName()
|
|
88
|
+
const multParam = randomName()
|
|
89
|
+
const outVar = randomName()
|
|
90
|
+
const iVar = randomName()
|
|
91
|
+
const nKeysVar = randomName()
|
|
92
|
+
|
|
93
|
+
const idxExpr = binary(
|
|
94
|
+
"+",
|
|
95
|
+
binary("%", binary("-", identifier(iVar), vmNumberLiteral(1)), identifier(nKeysVar)),
|
|
96
|
+
vmNumberLiteral(1),
|
|
97
|
+
)
|
|
98
|
+
const keyByte = index(identifier(keysParam), idxExpr)
|
|
99
|
+
const mixed = call(member(identifier("bit32"), "band"), [
|
|
100
|
+
call(member(identifier("bit32"), "lrotate"), [keyByte, binary("%", identifier(iVar), vmNumberLiteral(8))]),
|
|
101
|
+
vmNumberLiteral(0xFF),
|
|
102
|
+
])
|
|
103
|
+
const posByte = call(member(identifier("bit32"), "band"), [
|
|
104
|
+
binary("*", identifier(iVar), identifier(multParam)),
|
|
105
|
+
vmNumberLiteral(0xFF),
|
|
106
|
+
])
|
|
107
|
+
const keystream = call(member(identifier("bit32"), "bxor"), [mixed, posByte])
|
|
108
|
+
const finalByte = call(member(identifier("bit32"), "bxor"), [
|
|
109
|
+
index(identifier(dataParam), identifier(iVar)),
|
|
110
|
+
keystream,
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
const body = block([
|
|
114
|
+
localStatement(nKeysVar, unary("#", identifier(keysParam))),
|
|
115
|
+
localStatement(outVar, table([])),
|
|
116
|
+
numericForStatement(
|
|
117
|
+
iVar,
|
|
118
|
+
vmNumberLiteral(1),
|
|
119
|
+
unary("#", identifier(dataParam)),
|
|
120
|
+
block([
|
|
121
|
+
assignmentStatement(
|
|
122
|
+
[index(identifier(outVar), identifier(iVar))],
|
|
123
|
+
[call(member(identifier("string"), "char"), [finalByte])],
|
|
124
|
+
),
|
|
125
|
+
]),
|
|
126
|
+
),
|
|
127
|
+
returnStatement([call(member(identifier("table"), "concat"), [identifier(outVar)])]),
|
|
128
|
+
])
|
|
129
|
+
|
|
130
|
+
return localFunctionStatement(
|
|
131
|
+
name,
|
|
132
|
+
functionBody([functionParam(dataParam), functionParam(keysParam), functionParam(multParam)], body),
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function runEncryptStrings(program: Program, _options: EncryptStringsOptions): void {
|
|
137
|
+
const decoderName = randomName()
|
|
138
|
+
let used = false
|
|
139
|
+
|
|
140
|
+
transformExpressions(program, (expr: Expression) => {
|
|
141
|
+
if (expr.type !== "StringLiteral") return
|
|
142
|
+
if (expr.value.length === 0) return
|
|
143
|
+
|
|
144
|
+
used = true
|
|
145
|
+
|
|
146
|
+
const keyLen = randomInt(MIN_KEY_LEN, MAX_KEY_LEN)
|
|
147
|
+
const keys = Array.from({ length: keyLen }, () => randomInt(1, 255))
|
|
148
|
+
const mult = randomInt(1, 255)
|
|
149
|
+
|
|
150
|
+
const plainBytes = toUtf8Bytes(expr.value)
|
|
151
|
+
const cipherBytes = plainBytes.map((b, idx) => b ^ keystreamByte(keys, mult, idx + 1))
|
|
152
|
+
|
|
153
|
+
const dataTable = table(cipherBytes.map((b) => positionalField(vmNumberLiteral(b))))
|
|
154
|
+
const keysTable = table(keys.map((k) => positionalField(obfuscatedNumber(k))))
|
|
155
|
+
const multExpr = obfuscatedNumber(mult)
|
|
156
|
+
|
|
157
|
+
return call(identifier(decoderName), [dataTable, keysTable, multExpr])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
if (!used) return
|
|
161
|
+
|
|
162
|
+
program.body.statements.unshift(buildDecoderStatement(decoderName))
|
|
83
163
|
}
|
|
@@ -1,194 +1,194 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
Program, Block, Statement, Expression, Identifier, ScopeAnalysis,
|
|
3
|
-
} from "luau-parser"
|
|
4
|
-
import { analyzeScopes, isGlobal, getBinding } from "luau-parser"
|
|
5
|
-
import {
|
|
6
|
-
identifier, numberLiteral, stringLiteral, index as indexExpr,
|
|
7
|
-
table, computedField, localStatement,
|
|
8
|
-
} from "./nodeFactory"
|
|
9
|
-
|
|
10
|
-
export interface GlobalMappingOptions {
|
|
11
|
-
/** 전역들을 담아둘 최상단 local 테이블 변수 이름. RenameVariables가 이후에
|
|
12
|
-
* 다시 실행되면 이 이름도 다른 이름으로 한 번 더 바뀜. */
|
|
13
|
-
tableName: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
type Key = number | string
|
|
17
|
-
|
|
18
|
-
function randomInt(min: number, max: number): number {
|
|
19
|
-
return Math.floor(Math.random() * (max - min + 1)) + min
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function randomKey(): Key {
|
|
23
|
-
// 숫자 키/문자열 키를 섞어서 접근 패턴을 예측하기 어렵게 함
|
|
24
|
-
if (Math.random() < 0.5) return randomInt(1, 50)
|
|
25
|
-
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
26
|
-
const len = randomInt(2, 4)
|
|
27
|
-
let s = ""
|
|
28
|
-
for (let i = 0; i < len; i++) s += chars[randomInt(0, chars.length - 1)]
|
|
29
|
-
return s
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function randomPath(): Key[] {
|
|
33
|
-
const depth = randomInt(2, 4)
|
|
34
|
-
const path: Key[] = []
|
|
35
|
-
for (let i = 0; i < depth; i++) path.push(randomKey())
|
|
36
|
-
return path
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
type TreeLeaf = { leaf: string }
|
|
40
|
-
type TreeNode = Map<Key, TreeNode | TreeLeaf>
|
|
41
|
-
|
|
42
|
-
function isLeaf(v: TreeNode | TreeLeaf): v is TreeLeaf {
|
|
43
|
-
return !(v instanceof Map)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** path를 따라 내려가며 중간 노드를 만들고 마지막 키에 리프를 심음.
|
|
47
|
-
* 경로 충돌(이미 다른 값이 있음)이 나면 false를 반환 — 호출 쪽에서 다른 경로로 재시도. */
|
|
48
|
-
function insertPath(root: TreeNode, path: Key[], globalName: string): boolean {
|
|
49
|
-
let node = root
|
|
50
|
-
for (let i = 0; i < path.length - 1; i++) {
|
|
51
|
-
const key = path[i]
|
|
52
|
-
let next = node.get(key)
|
|
53
|
-
if (next === undefined) {
|
|
54
|
-
next = new Map()
|
|
55
|
-
node.set(key, next)
|
|
56
|
-
} else if (isLeaf(next)) {
|
|
57
|
-
return false
|
|
58
|
-
}
|
|
59
|
-
node = next as TreeNode
|
|
60
|
-
}
|
|
61
|
-
const lastKey = path[path.length - 1]
|
|
62
|
-
if (node.has(lastKey)) return false
|
|
63
|
-
node.set(lastKey, { leaf: globalName })
|
|
64
|
-
return true
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function assignPaths(globalNames: string[]): { root: TreeNode; paths: Map<string, Key[]> } {
|
|
68
|
-
const root: TreeNode = new Map()
|
|
69
|
-
const paths = new Map<string, Key[]>()
|
|
70
|
-
|
|
71
|
-
for (const name of globalNames) {
|
|
72
|
-
let path: Key[] = []
|
|
73
|
-
let attempts = 0
|
|
74
|
-
let ok = false
|
|
75
|
-
while (!ok && attempts < 200) {
|
|
76
|
-
path = randomPath()
|
|
77
|
-
ok = insertPath(root, path, name)
|
|
78
|
-
attempts++
|
|
79
|
-
}
|
|
80
|
-
paths.set(name, path)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return { root, paths }
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function keyExpression(key: Key): Expression {
|
|
87
|
-
return typeof key === "number" ? numberLiteral(key) : stringLiteral(key)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function buildTreeTableExpr(node: TreeNode): Expression {
|
|
91
|
-
const fields = [...node.entries()].map(([key, value]) =>
|
|
92
|
-
computedField(
|
|
93
|
-
keyExpression(key),
|
|
94
|
-
isLeaf(value) ? identifier(value.leaf) : buildTreeTableExpr(value),
|
|
95
|
-
),
|
|
96
|
-
)
|
|
97
|
-
return table(fields)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function buildIndexChain(tableName: string, path: Key[]): Expression {
|
|
101
|
-
let expr: Expression = identifier(tableName)
|
|
102
|
-
for (const key of path) {
|
|
103
|
-
expr = indexExpr(expr, keyExpression(key))
|
|
104
|
-
}
|
|
105
|
-
return expr
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** node(원래 Identifier)를 같은 객체 참조를 유지한 채로 IndexExpression으로
|
|
109
|
-
* 제자리 변형함 — 이 객체를 들고 있는 부모 필드(BinaryExpression.left 등)는
|
|
110
|
-
* 어디 있는지 몰라도 되고, 그냥 이 객체가 바뀌면 자동으로 반영됨. */
|
|
111
|
-
function morphIntoIndexChain(node: Identifier, tableName: string, path: Key[]): void {
|
|
112
|
-
const built = buildIndexChain(tableName, path) as unknown as Record<string, unknown>
|
|
113
|
-
const target = node as unknown as Record<string, unknown>
|
|
114
|
-
for (const k of Object.keys(target)) delete target[k]
|
|
115
|
-
Object.assign(target, built)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* `function Foo() end` / `function T.m() end` 형태의 target.base는 리터럴
|
|
120
|
-
* 이름만 허용되는 문법 자리라 인덱스 체인으로 바꿀 수 없음. 이런 자리에 쓰인
|
|
121
|
-
* 전역 이름은 매핑 대상에서 제외한다.
|
|
122
|
-
* (statement 트리를 따라 내려가며 찾음 — 익명함수 표현식 내부에 중첩된
|
|
123
|
-
* 전역 함수 선언 같은 극단적 케이스는 대상에서 빠질 수 있음)
|
|
124
|
-
*/
|
|
125
|
-
function collectUnsafeFunctionDeclGlobals(program: Program, analysis: ScopeAnalysis): Set<string> {
|
|
126
|
-
const unsafe = new Set<string>()
|
|
127
|
-
|
|
128
|
-
function visitBlock(block: Block): void {
|
|
129
|
-
for (const stmt of block.statements) visitStatement(stmt)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function visitStatement(stmt: Statement): void {
|
|
133
|
-
switch (stmt.type) {
|
|
134
|
-
case "FunctionDeclarationStatement": {
|
|
135
|
-
const binding = getBinding(analysis, stmt.target.base)
|
|
136
|
-
if (binding && isGlobal(binding)) unsafe.add(binding.name)
|
|
137
|
-
visitBlock(stmt.func.body)
|
|
138
|
-
return
|
|
139
|
-
}
|
|
140
|
-
case "LocalFunctionStatement":
|
|
141
|
-
visitBlock(stmt.func.body)
|
|
142
|
-
return
|
|
143
|
-
case "DoStatement":
|
|
144
|
-
visitBlock(stmt.body)
|
|
145
|
-
return
|
|
146
|
-
case "WhileStatement":
|
|
147
|
-
visitBlock(stmt.body)
|
|
148
|
-
return
|
|
149
|
-
case "RepeatStatement":
|
|
150
|
-
visitBlock(stmt.body)
|
|
151
|
-
return
|
|
152
|
-
case "IfStatement":
|
|
153
|
-
for (const clause of stmt.clauses) visitBlock(clause.body)
|
|
154
|
-
if (stmt.alternate) visitBlock(stmt.alternate)
|
|
155
|
-
return
|
|
156
|
-
case "NumericForStatement":
|
|
157
|
-
visitBlock(stmt.body)
|
|
158
|
-
return
|
|
159
|
-
case "GenericForStatement":
|
|
160
|
-
visitBlock(stmt.body)
|
|
161
|
-
return
|
|
162
|
-
default:
|
|
163
|
-
return
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
visitBlock(program.body)
|
|
168
|
-
return unsafe
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export function runGlobalMapping(program: Program, options: GlobalMappingOptions): void {
|
|
172
|
-
const analysis = analyzeScopes(program)
|
|
173
|
-
const unsafe = collectUnsafeFunctionDeclGlobals(program, analysis)
|
|
174
|
-
|
|
175
|
-
const globalBindings = [...analysis.bindings.values()].filter(
|
|
176
|
-
(b) => isGlobal(b) && b.references.length > 0 && !unsafe.has(b.name),
|
|
177
|
-
)
|
|
178
|
-
if (globalBindings.length === 0) return
|
|
179
|
-
|
|
180
|
-
const { root, paths } = assignPaths(globalBindings.map((b) => b.name))
|
|
181
|
-
|
|
182
|
-
for (const binding of globalBindings) {
|
|
183
|
-
const path = paths.get(binding.name)!
|
|
184
|
-
// declarationNode(대입으로 정의된 경우)는 이미 references에 포함된
|
|
185
|
-
// 같은 객체라 references만 돌면 전부 커버됨
|
|
186
|
-
for (const ref of binding.references) {
|
|
187
|
-
morphIntoIndexChain(ref, options.tableName, path)
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
program.body.statements.unshift(
|
|
192
|
-
localStatement(options.tableName, buildTreeTableExpr(root)),
|
|
193
|
-
)
|
|
1
|
+
import type {
|
|
2
|
+
Program, Block, Statement, Expression, Identifier, ScopeAnalysis,
|
|
3
|
+
} from "luau-parser"
|
|
4
|
+
import { analyzeScopes, isGlobal, getBinding } from "luau-parser"
|
|
5
|
+
import {
|
|
6
|
+
identifier, numberLiteral, stringLiteral, index as indexExpr,
|
|
7
|
+
table, computedField, localStatement,
|
|
8
|
+
} from "./nodeFactory"
|
|
9
|
+
|
|
10
|
+
export interface GlobalMappingOptions {
|
|
11
|
+
/** 전역들을 담아둘 최상단 local 테이블 변수 이름. RenameVariables가 이후에
|
|
12
|
+
* 다시 실행되면 이 이름도 다른 이름으로 한 번 더 바뀜. */
|
|
13
|
+
tableName: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Key = number | string
|
|
17
|
+
|
|
18
|
+
function randomInt(min: number, max: number): number {
|
|
19
|
+
return Math.floor(Math.random() * (max - min + 1)) + min
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function randomKey(): Key {
|
|
23
|
+
// 숫자 키/문자열 키를 섞어서 접근 패턴을 예측하기 어렵게 함
|
|
24
|
+
if (Math.random() < 0.5) return randomInt(1, 50)
|
|
25
|
+
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
26
|
+
const len = randomInt(2, 4)
|
|
27
|
+
let s = ""
|
|
28
|
+
for (let i = 0; i < len; i++) s += chars[randomInt(0, chars.length - 1)]
|
|
29
|
+
return s
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function randomPath(): Key[] {
|
|
33
|
+
const depth = randomInt(2, 4)
|
|
34
|
+
const path: Key[] = []
|
|
35
|
+
for (let i = 0; i < depth; i++) path.push(randomKey())
|
|
36
|
+
return path
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type TreeLeaf = { leaf: string }
|
|
40
|
+
type TreeNode = Map<Key, TreeNode | TreeLeaf>
|
|
41
|
+
|
|
42
|
+
function isLeaf(v: TreeNode | TreeLeaf): v is TreeLeaf {
|
|
43
|
+
return !(v instanceof Map)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** path를 따라 내려가며 중간 노드를 만들고 마지막 키에 리프를 심음.
|
|
47
|
+
* 경로 충돌(이미 다른 값이 있음)이 나면 false를 반환 — 호출 쪽에서 다른 경로로 재시도. */
|
|
48
|
+
function insertPath(root: TreeNode, path: Key[], globalName: string): boolean {
|
|
49
|
+
let node = root
|
|
50
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
51
|
+
const key = path[i]
|
|
52
|
+
let next = node.get(key)
|
|
53
|
+
if (next === undefined) {
|
|
54
|
+
next = new Map()
|
|
55
|
+
node.set(key, next)
|
|
56
|
+
} else if (isLeaf(next)) {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
node = next as TreeNode
|
|
60
|
+
}
|
|
61
|
+
const lastKey = path[path.length - 1]
|
|
62
|
+
if (node.has(lastKey)) return false
|
|
63
|
+
node.set(lastKey, { leaf: globalName })
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assignPaths(globalNames: string[]): { root: TreeNode; paths: Map<string, Key[]> } {
|
|
68
|
+
const root: TreeNode = new Map()
|
|
69
|
+
const paths = new Map<string, Key[]>()
|
|
70
|
+
|
|
71
|
+
for (const name of globalNames) {
|
|
72
|
+
let path: Key[] = []
|
|
73
|
+
let attempts = 0
|
|
74
|
+
let ok = false
|
|
75
|
+
while (!ok && attempts < 200) {
|
|
76
|
+
path = randomPath()
|
|
77
|
+
ok = insertPath(root, path, name)
|
|
78
|
+
attempts++
|
|
79
|
+
}
|
|
80
|
+
paths.set(name, path)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { root, paths }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function keyExpression(key: Key): Expression {
|
|
87
|
+
return typeof key === "number" ? numberLiteral(key) : stringLiteral(key)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildTreeTableExpr(node: TreeNode): Expression {
|
|
91
|
+
const fields = [...node.entries()].map(([key, value]) =>
|
|
92
|
+
computedField(
|
|
93
|
+
keyExpression(key),
|
|
94
|
+
isLeaf(value) ? identifier(value.leaf) : buildTreeTableExpr(value),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
return table(fields)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildIndexChain(tableName: string, path: Key[]): Expression {
|
|
101
|
+
let expr: Expression = identifier(tableName)
|
|
102
|
+
for (const key of path) {
|
|
103
|
+
expr = indexExpr(expr, keyExpression(key))
|
|
104
|
+
}
|
|
105
|
+
return expr
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** node(원래 Identifier)를 같은 객체 참조를 유지한 채로 IndexExpression으로
|
|
109
|
+
* 제자리 변형함 — 이 객체를 들고 있는 부모 필드(BinaryExpression.left 등)는
|
|
110
|
+
* 어디 있는지 몰라도 되고, 그냥 이 객체가 바뀌면 자동으로 반영됨. */
|
|
111
|
+
function morphIntoIndexChain(node: Identifier, tableName: string, path: Key[]): void {
|
|
112
|
+
const built = buildIndexChain(tableName, path) as unknown as Record<string, unknown>
|
|
113
|
+
const target = node as unknown as Record<string, unknown>
|
|
114
|
+
for (const k of Object.keys(target)) delete target[k]
|
|
115
|
+
Object.assign(target, built)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `function Foo() end` / `function T.m() end` 형태의 target.base는 리터럴
|
|
120
|
+
* 이름만 허용되는 문법 자리라 인덱스 체인으로 바꿀 수 없음. 이런 자리에 쓰인
|
|
121
|
+
* 전역 이름은 매핑 대상에서 제외한다.
|
|
122
|
+
* (statement 트리를 따라 내려가며 찾음 — 익명함수 표현식 내부에 중첩된
|
|
123
|
+
* 전역 함수 선언 같은 극단적 케이스는 대상에서 빠질 수 있음)
|
|
124
|
+
*/
|
|
125
|
+
function collectUnsafeFunctionDeclGlobals(program: Program, analysis: ScopeAnalysis): Set<string> {
|
|
126
|
+
const unsafe = new Set<string>()
|
|
127
|
+
|
|
128
|
+
function visitBlock(block: Block): void {
|
|
129
|
+
for (const stmt of block.statements) visitStatement(stmt)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function visitStatement(stmt: Statement): void {
|
|
133
|
+
switch (stmt.type) {
|
|
134
|
+
case "FunctionDeclarationStatement": {
|
|
135
|
+
const binding = getBinding(analysis, stmt.target.base)
|
|
136
|
+
if (binding && isGlobal(binding)) unsafe.add(binding.name)
|
|
137
|
+
visitBlock(stmt.func.body)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
case "LocalFunctionStatement":
|
|
141
|
+
visitBlock(stmt.func.body)
|
|
142
|
+
return
|
|
143
|
+
case "DoStatement":
|
|
144
|
+
visitBlock(stmt.body)
|
|
145
|
+
return
|
|
146
|
+
case "WhileStatement":
|
|
147
|
+
visitBlock(stmt.body)
|
|
148
|
+
return
|
|
149
|
+
case "RepeatStatement":
|
|
150
|
+
visitBlock(stmt.body)
|
|
151
|
+
return
|
|
152
|
+
case "IfStatement":
|
|
153
|
+
for (const clause of stmt.clauses) visitBlock(clause.body)
|
|
154
|
+
if (stmt.alternate) visitBlock(stmt.alternate)
|
|
155
|
+
return
|
|
156
|
+
case "NumericForStatement":
|
|
157
|
+
visitBlock(stmt.body)
|
|
158
|
+
return
|
|
159
|
+
case "GenericForStatement":
|
|
160
|
+
visitBlock(stmt.body)
|
|
161
|
+
return
|
|
162
|
+
default:
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
visitBlock(program.body)
|
|
168
|
+
return unsafe
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function runGlobalMapping(program: Program, options: GlobalMappingOptions): void {
|
|
172
|
+
const analysis = analyzeScopes(program)
|
|
173
|
+
const unsafe = collectUnsafeFunctionDeclGlobals(program, analysis)
|
|
174
|
+
|
|
175
|
+
const globalBindings = [...analysis.bindings.values()].filter(
|
|
176
|
+
(b) => isGlobal(b) && b.references.length > 0 && !unsafe.has(b.name),
|
|
177
|
+
)
|
|
178
|
+
if (globalBindings.length === 0) return
|
|
179
|
+
|
|
180
|
+
const { root, paths } = assignPaths(globalBindings.map((b) => b.name))
|
|
181
|
+
|
|
182
|
+
for (const binding of globalBindings) {
|
|
183
|
+
const path = paths.get(binding.name)!
|
|
184
|
+
// declarationNode(대입으로 정의된 경우)는 이미 references에 포함된
|
|
185
|
+
// 같은 객체라 references만 돌면 전부 커버됨
|
|
186
|
+
for (const ref of binding.references) {
|
|
187
|
+
morphIntoIndexChain(ref, options.tableName, path)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
program.body.statements.unshift(
|
|
192
|
+
localStatement(options.tableName, buildTreeTableExpr(root)),
|
|
193
|
+
)
|
|
194
194
|
}
|