luau-obfuscator 1.0.3 → 1.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luau-obfuscator",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "luau obfuscator for roblox",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -37,6 +37,13 @@ export interface VmifyOptions {
37
37
  * Math.random. 재현 가능한 빌드가 필요하면(테스트 등) 시드 고정 PRNG를 넘길 것.
38
38
  */
39
39
  random?: () => number
40
+ /**
41
+ * 디버그 빌드. 켜면 각 인스트럭션에 원본 소스 줄 번호를, CALL에는 호출 대상 이름을
42
+ * 함께 직렬화하고, 런타임 CALL 핸들러가 non-callable 값을 부르려 할 때 그냥
43
+ * "attempt to call a nil value"가 아니라 "[vmdebug] ... '대상이름' (source line N)"으로
44
+ * 원본 위치를 찍어준다. 산출물이 조금 커지고 VM 시그니처가 노출되므로 진단용으로만 쓸 것.
45
+ */
46
+ debug?: boolean
40
47
  }
41
48
 
42
49
  /**
@@ -59,10 +66,11 @@ export function runVmify(program: Program, options: VmifyOptions): void {
59
66
  const builtinGlobals = options.builtinGlobals ?? DEFAULT_BUILTIN_GLOBALS
60
67
  const random = options.random ?? Math.random
61
68
 
69
+ const debug = options.debug ?? false
62
70
  const opcodeMap = createOpcodeMap(random)
63
71
  const names = generateVmNames(random)
64
72
 
65
- const compiler = new VmCompiler(program, builtinGlobals, opcodeMap)
73
+ const compiler = new VmCompiler(program, builtinGlobals, opcodeMap, debug)
66
74
  const topProto = compiler.compile()
67
75
 
68
76
  // builtinGlobals(시드 목록)가 아니라 실제로 프로그램이 참조한 전역 전체로 브릿지를
@@ -74,12 +82,12 @@ export function runVmify(program: Program, options: VmifyOptions): void {
74
82
  usedGlobalNames.map((name) => namedField(name, identifier(name))),
75
83
  )
76
84
 
77
- const runtimeSource = buildVmRuntimeSource(names, opcodeMap)
85
+ const runtimeSource = buildVmRuntimeSource(names, opcodeMap, debug)
78
86
  const runtimeProgram = luauparser.parse(runtimeSource)
79
87
  const runtimeStatements: Statement[] = runtimeProgram.body.statements
80
88
  markRuntimeNumbersAsStructural(runtimeProgram.body)
81
89
 
82
- const protoLiteral = serializeProto(topProto, names)
90
+ const protoLiteral = serializeProto(topProto, names, debug)
83
91
 
84
92
  const newBody: Statement[] = [
85
93
  localStatement(names.globals, globalsTable),
@@ -33,6 +33,8 @@ interface FuncState {
33
33
  upvalIndexByBinding: Map<BindingId, number>
34
34
  breakPatchStack: number[][]
35
35
  continuePatchStack: number[][]
36
+ /** debug 빌드에서 emit()이 인스트럭션에 박아넣을 "현재 컴파일 중인 소스 줄". */
37
+ curLine?: number
36
38
  }
37
39
 
38
40
  export class VmCompiler {
@@ -64,6 +66,7 @@ export class VmCompiler {
64
66
  private program: Program,
65
67
  builtinGlobals: readonly string[] = DEFAULT_BUILTIN_GLOBALS,
66
68
  private opcodeMap?: OpcodeMap,
69
+ private debug = false,
67
70
  ) {
68
71
  this.analysis = luauparser.analyzeScopes(program, { builtinGlobals })
69
72
  this.enclosing = buildEnclosingFunctionMap(program)
@@ -115,10 +118,34 @@ export class VmCompiler {
115
118
  // 물리 번호로 바꿔서 저장한다. 직접 VmCompiler를 쓰는 테스트/디버깅 코드가
116
119
  // opcodeMap 없이 호출할 수도 있으니 없으면 항등(그대로) 매핑으로 폴백.
117
120
  const physicalOp = this.opcodeMap ? this.opcodeMap[op] : op
118
- state.proto.code.push({ op: physicalOp, a, b, c, comment } as Instr)
121
+ state.proto.code.push({ op: physicalOp, a, b, c, comment, dbgLine: this.debug ? state.curLine : undefined } as Instr)
119
122
  return state.proto.code.length - 1
120
123
  }
121
124
 
125
+ /** debug 빌드: 방금 emit한 CALL 인스트럭션에 "호출 대상 표현식 문자열"과 그 소스 줄을
126
+ * 박아넣는다(런타임이 non-callable을 잡았을 때 사람이 읽을 위치를 찍게). */
127
+ private stampCall(state: FuncState, idx: number, callExpr: Expression, calleeExpr: Expression): void {
128
+ if (!this.debug) return
129
+ const instr = state.proto.code[idx]
130
+ instr.dbgName = this.describeExpr(calleeExpr)
131
+ const line = (calleeExpr as { line?: { start: number } }).line ?? (callExpr as { line?: { start: number } }).line
132
+ if (line) instr.dbgLine = line.start
133
+ }
134
+
135
+ /** 표현식을 사람이 읽을 짧은 문자열로. 위치 추적용이라 완벽할 필요는 없음. */
136
+ private describeExpr(e: Expression): string {
137
+ switch (e.type) {
138
+ case "Identifier": return e.name
139
+ case "MemberExpression": return `${this.describeExpr(e.object)}.${e.property.name}`
140
+ case "MethodCallExpression": return `${this.describeExpr(e.object)}:${e.method.name}(...)`
141
+ case "IndexExpression": return `${this.describeExpr(e.object)}[...]`
142
+ case "CallExpression": return `${this.describeExpr(e.callee)}(...)`
143
+ case "ParenthesizedExpression": return `(${this.describeExpr(e.expression)})`
144
+ case "StringLiteral": return `"${String(e.value).slice(0, 20)}"`
145
+ default: return `<${e.type}>`
146
+ }
147
+ }
148
+
122
149
  private konst(state: FuncState, value: ConstValue): number {
123
150
  return internConst(state.proto, value)
124
151
  }
@@ -219,6 +246,7 @@ export class VmCompiler {
219
246
  }
220
247
 
221
248
  private compileStatement(stmt: Statement, state: FuncState): void {
249
+ if (this.debug && (stmt as { line?: { start: number } }).line) state.curLine = (stmt as { line: { start: number } }).line.start
222
250
  switch (stmt.type) {
223
251
  case "LocalStatement": {
224
252
  // 순서 중요: 로컬(및 캡처된 것의 박스)을 "먼저" 선언해서 레지스터를 확정한 뒤에
@@ -253,11 +281,39 @@ export class VmCompiler {
253
281
  return
254
282
  }
255
283
  case "AssignmentStatement": {
256
- const valueRegs = stmt.values.map((e) => this.compileExpr(e, state))
257
- stmt.targets.forEach((target, i) => {
258
- const srcReg = valueRegs[i] ?? valueRegs[valueRegs.length - 1]
259
- this.compileAssignTarget(target, srcReg, state)
260
- })
284
+ // LocalStatement와 동일한 다중값 규칙을 따라야 한다: 우변의 "마지막" 식이
285
+ // Call/MethodCall/Vararg면 남는 대상 개수만큼 펼쳐야 하고(`a, b = f()` →
286
+ // a=1번째, b=2번째 반환값), 그 외엔 각자 1개씩. 예전 구현은 각 값을 따로
287
+ // compileExpr해서 대상 개수보다 값이 적으면 "마지막 값 하나를 재사용"했는데,
288
+ // 그 바람에 `Tools, Manager = Nova.Create()`에서 Manager가 2번째 반환값이
289
+ // 아니라 Tools(1번째)를 그대로 받아 "attempt to call a nil value"로 터졌다.
290
+ //
291
+ // 모든 우변을 먼저 연속 레지스터 블록(base..)에 계산한 뒤 대상에 대입한다 —
292
+ // 이 순서라야 `a, b = b, a`(스왑)처럼 대입 전에 우변을 전부 평가하는
293
+ // Lua 의미가 보존된다.
294
+ const targets = stmt.targets
295
+ const values = stmt.values
296
+ const base = state.regs.top()
297
+ if (values.length > 0) {
298
+ for (let i = 0; i < values.length - 1; i++) {
299
+ this.compileExprTo(values[i], state, base + i)
300
+ }
301
+ const lastIdx = values.length - 1
302
+ const remaining = Math.max(targets.length - lastIdx, 1)
303
+ this.compileExprMultiInto(values[lastIdx], state, base + lastIdx, remaining)
304
+ }
305
+ // 값보다 대상이 많으면 나머지 대상은 nil.
306
+ const produced = values.length === 0 ? 0 : (values.length - 1) + Math.max(targets.length - (values.length - 1), 1)
307
+ for (let i = produced; i < targets.length; i++) {
308
+ this.emit(state, Opcode.LOADNIL, base + i, base + i, 0)
309
+ }
310
+ // 대입: 대상 프리픽스(테이블/키) 평가용 임시가 아직 안 쓴 값 슬롯을 덮지
311
+ // 않도록, 매 대입 전에 freereg를 값 블록 위(base+targets.length)로 올려둔다.
312
+ for (let i = 0; i < targets.length; i++) {
313
+ state.regs.freeTemp(base + targets.length)
314
+ this.compileAssignTarget(targets[i], base + i, state)
315
+ }
316
+ state.regs.freeTemp(base)
261
317
  return
262
318
  }
263
319
  case "CompoundAssignmentStatement":
@@ -635,7 +691,7 @@ export class VmCompiler {
635
691
  // CallExpression과 동일한 이유로 top()을 미리 캐싱하지 않고 callee의 반환값을 base로 씀.
636
692
  const base = this.compileExpr(callExpr.callee, state)
637
693
  this.compileArgsContiguous(callExpr.arguments, state, base + 1)
638
- this.emit(state, Opcode.CALL, base, callExpr.arguments.length + 1, wantCount + 1)
694
+ this.stampCall(state, this.emit(state, Opcode.CALL, base, callExpr.arguments.length + 1, wantCount + 1), callExpr, callExpr.callee)
639
695
  return base
640
696
  }
641
697
 
@@ -694,7 +750,7 @@ export class VmCompiler {
694
750
  if (expr.type === "CallExpression") {
695
751
  const calleeReg = this.compileExpr(expr.callee, state)
696
752
  const b = this.compileCallArgsAndGetB(expr.arguments, state, calleeReg + 1)
697
- this.emit(state, Opcode.CALL, calleeReg, b, wantCount + 1)
753
+ this.stampCall(state, this.emit(state, Opcode.CALL, calleeReg, b, wantCount + 1), expr, expr.callee)
698
754
  for (let i = 0; i < wantCount; i++) {
699
755
  if (calleeReg + i !== base + i) this.emit(state, Opcode.MOVE, base + i, calleeReg + i, 0)
700
756
  }
@@ -709,7 +765,7 @@ export class VmCompiler {
709
765
  state.regs.allocTemp()
710
766
  const argB = this.compileCallArgsAndGetB(expr.arguments, state, fnSlot + 2)
711
767
  const b = argB === 0 ? 0 : expr.arguments.length + 2
712
- this.emit(state, Opcode.CALL, fnSlot, b, wantCount + 1)
768
+ this.stampCall(state, this.emit(state, Opcode.CALL, fnSlot, b, wantCount + 1), expr, expr)
713
769
  for (let i = 0; i < wantCount; i++) {
714
770
  if (fnSlot + i !== base + i) this.emit(state, Opcode.MOVE, base + i, fnSlot + i, 0)
715
771
  }
@@ -740,7 +796,7 @@ export class VmCompiler {
740
796
  if (calleeReg !== base) this.emit(state, Opcode.MOVE, base, calleeReg, 0)
741
797
  state.regs.freeTemp(base + 1)
742
798
  const b = this.compileCallArgsAndGetB(expr.arguments, state, base + 1)
743
- this.emit(state, Opcode.CALL, base, b, 0)
799
+ this.stampCall(state, this.emit(state, Opcode.CALL, base, b, 0), expr, expr.callee)
744
800
  return
745
801
  }
746
802
  if (expr.type === "MethodCallExpression") {
@@ -756,7 +812,7 @@ export class VmCompiler {
756
812
  state.regs.freeTemp(base + 2)
757
813
  const argB = this.compileCallArgsAndGetB(expr.arguments, state, base + 2)
758
814
  const b = argB === 0 ? 0 : expr.arguments.length + 2
759
- this.emit(state, Opcode.CALL, base, b, 0)
815
+ this.stampCall(state, this.emit(state, Opcode.CALL, base, b, 0), expr, expr)
760
816
  return
761
817
  }
762
818
  if (expr.type === "VarargExpression") {
@@ -785,6 +841,7 @@ export class VmCompiler {
785
841
  }
786
842
 
787
843
  private compileExpr(expr: Expression, state: FuncState, discard = false): number {
844
+ if (this.debug && (expr as { line?: { start: number } }).line) state.curLine = (expr as { line: { start: number } }).line.start
788
845
  switch (expr.type) {
789
846
  case "NilLiteral": {
790
847
  const r = state.regs.allocTemp()
@@ -914,7 +971,8 @@ export class VmCompiler {
914
971
  // 쓴 임시 레지스터 때문에 함수 값 자체는 그 다음 슬롯에 옴).
915
972
  const base = this.compileExpr(expr.callee, state)
916
973
  const b = this.compileCallArgsAndGetB(expr.arguments, state, base + 1)
917
- this.emit(state, Opcode.CALL, base, b, discard ? 1 : 2)
974
+ const callIdx = this.emit(state, Opcode.CALL, base, b, discard ? 1 : 2)
975
+ this.stampCall(state, callIdx, expr, expr.callee)
918
976
  state.regs.freeTemp(base + (discard ? 0 : 1))
919
977
  return base
920
978
  }
@@ -928,7 +986,8 @@ export class VmCompiler {
928
986
  // self가 항상 고정으로 하나 더 붙으므로: 열려있으면(0) 그대로 0(런타임이
929
987
  // multiTop까지 다 잡아줌, self도 그 범위 안에 포함됨), 아니면 +1(self 몫).
930
988
  const b = argB === 0 ? 0 : expr.arguments.length + 2
931
- this.emit(state, Opcode.CALL, fnSlot, b, discard ? 1 : 2)
989
+ const callIdx = this.emit(state, Opcode.CALL, fnSlot, b, discard ? 1 : 2)
990
+ this.stampCall(state, callIdx, expr, expr)
932
991
  state.regs.freeTemp(fnSlot + (discard ? 0 : 1))
933
992
  return fnSlot
934
993
  }
@@ -50,6 +50,10 @@ export interface Instr {
50
50
  c: number
51
51
  /** JMP/EQ/LT/LE/TEST/CALL 등에서 사람이 읽을 디버깅용 주석. 런타임엔 안 쓰임. */
52
52
  comment?: string
53
+ /** debug 빌드에서만: 이 인스트럭션이 나온 원본 소스(입력 .luau) 줄 번호. */
54
+ dbgLine?: number
55
+ /** debug 빌드에서만: CALL이면 호출 대상 표현식을 사람이 읽을 문자열로(예: "print", "obj:method"). */
56
+ dbgName?: string
53
57
  }
54
58
 
55
59
  /** RK 인코딩 헬퍼: 상수는 음수(-idx-1), 레지스터는 그대로 양수. */
@@ -2,17 +2,24 @@ import { Opcode, type OpcodeMap } from "./opcodes"
2
2
  import type { VmNames } from "./names"
3
3
 
4
4
  /**
5
- * VM 런타임 v3 — opcode마다 독립된 핸들러 함수를 만들고 서로 tail call로 이어지는
6
- * 구조는 v2와 동일하다. 다만 이제 모든 전역 식별자(__vm_execute, __vm_handlers, ...)와
7
- * frame/Instr/Proto의 테이블 필드 키가 고정 문자열이 아니라, 빌드마다 무작위로 뽑힌
8
- * names(names.ts)와 opcode 번호 재배치 opcodeMap(opcodes.ts)을 받아 그때그때
9
- * 소스를 새로 찍어낸다.
5
+ * VM 런타임 v4 — opcode마다 독립된 핸들러 함수를 만드는 구조는 v3와 동일하다.
6
+ * 모든 전역 식별자(__vm_execute, __vm_handlers, ...)와 frame/Instr/Proto의 테이블
7
+ * 필드 키가 고정 문자열이 아니라, 빌드마다 무작위로 뽑힌 names(names.ts)와 opcode 번호
8
+ * 재배치 opcodeMap(opcodes.ts)을 받아 그때그때 소스를 새로 찍어낸다.
10
9
  *
11
- * 왜 이렇게 하나:
12
- * - Luau는 proper tail call을 보장하므로(스택 안 쌓임, 성능도 loop와 동급),
13
- * "재귀처럼 보이지만 실제론 loop"인 구조를 공짜로 얻을 수 있다.
10
+ * v3와의 결정적 차이 — dispatch를 tail call 재귀가 아니라 while 루프로 돌린다:
11
+ * - v3는 "Luau가 proper tail call을 보장하므로 return dispatch(frame, pc+1)가
12
+ * 스택을 안 쌓는다"는 전제로 각 핸들러가 dispatch를 꼬리 호출로 이어붙였다.
13
+ * 그런데 Luau는 (Lua 5.x와 달리) proper tail call을 하지 않는다. 정확한 스택
14
+ * 트레이스/디버깅을 위해 의도적으로 뺐다. 그래서 명령어 하나 실행할 때마다
15
+ * dispatch 프레임이 실제로 하나씩 쌓였고, 명령어 수백 개짜리 함수만 돌려도
16
+ * stack overflow가 났다.
17
+ * - v4에서는 dispatch가 while 루프를 돌며 핸들러를 호출한다. 핸들러는 dispatch를
18
+ * 부르지 않고 "다음 pc(숫자)"를 반환하고, RETURN만 결과 패킷(table.pack 결과)을
19
+ * 반환한다. 루프는 반환값이 숫자면 pc를 갱신하고, 테이블이면 그걸 풀어서 반환한다.
20
+ * 이제 함수 하나를 끝까지 도는 동안 스택 깊이는 일정하다.
14
21
  * - "거대한 while+if-chain" 자체가 디컴파일러가 제일 먼저 찾는 VM 시그니처인데,
15
- * 이 구조는 그 패턴이 아예 없다.
22
+ * 루프 본문이 if-chain이 아니라 handlers[op](...) 단일 디스패치라 그 패턴은 아니다.
16
23
  * - opcode 번호 + 식별자 이름을 빌드마다 다르게 뽑기 때문에, 한 번 리버싱해서
17
24
  * "MOVE=0, __vm_execute라는 함수가 진입점" 같은 지식을 얻어도 다음 산출물에는
18
25
  * 그대로 안 먹힌다 — 매번 새로 분석해야 함.
@@ -21,16 +28,28 @@ import type { VmNames } from "./names"
21
28
  * (compiler.ts가 emit() 시점에 같은 opcodeMap으로 인스트럭션에 번호를 박아 넣으므로
22
29
  * 반드시 컴파일과 런타임 생성에 동일한 opcodeMap 인스턴스를 넘겨야 함).
23
30
  */
24
- export function buildVmRuntimeSource(names: VmNames, opcodeMap: OpcodeMap): string {
31
+ export function buildVmRuntimeSource(names: VmNames, opcodeMap: OpcodeMap, debug = false): string {
25
32
  const N = names
26
33
  const op = (o: Opcode) => opcodeMap[o]
27
34
 
28
35
  return `
29
36
  local ${N.handlers} = {}
30
37
 
38
+ -- 각 핸들러는 "다음 pc(숫자)"를 반환한다. RETURN 핸들러만 table.pack 결과(테이블)를
39
+ -- 반환하며, 그게 함수 실행 종료 신호다. Luau는 proper tail call을 하지 않으므로
40
+ -- 핸들러들이 서로 꼬리 호출로 이어지면 명령어당 스택 프레임이 쌓여 터진다 — 그래서
41
+ -- 여기서 while 루프로 돌려 스택 깊이를 함수 실행 내내 일정하게 유지한다.
31
42
  local function ${N.dispatch}(${N.frame}, pc)
32
- local instr = ${N.frame}.${N.code}[pc]
33
- return ${N.handlers}[instr[1]](${N.frame}, pc, instr)
43
+ local ${N.handlers} = ${N.handlers}
44
+ while true do
45
+ local instr = ${N.frame}.${N.code}[pc]
46
+ local next = ${N.handlers}[instr[1]](${N.frame}, pc, instr)
47
+ if type(next) == "number" then
48
+ pc = next
49
+ else
50
+ return table.unpack(next, 1, next.n)
51
+ end
52
+ end
34
53
  end
35
54
 
36
55
  local function ${N.rk}(${N.frame}, x)
@@ -44,67 +63,67 @@ end
44
63
  -- MOVE
45
64
  ${N.handlers}[${op(Opcode.MOVE)}] = function(${N.frame}, pc, instr)
46
65
  ${N.frame}.${N.R}[instr[2]] = ${N.frame}.${N.R}[instr[3]]
47
- return ${N.dispatch}(${N.frame}, pc + 1)
66
+ return pc + 1
48
67
  end
49
68
 
50
69
  -- LOADK
51
70
  ${N.handlers}[${op(Opcode.LOADK)}] = function(${N.frame}, pc, instr)
52
71
  ${N.frame}.${N.R}[instr[2]] = ${N.frame}.${N.K}[instr[3] + 1]
53
- return ${N.dispatch}(${N.frame}, pc + 1)
72
+ return pc + 1
54
73
  end
55
74
 
56
75
  -- LOADBOOL
57
76
  ${N.handlers}[${op(Opcode.LOADBOOL)}] = function(${N.frame}, pc, instr)
58
77
  ${N.frame}.${N.R}[instr[2]] = (instr[3] ~= 0)
59
- return ${N.dispatch}(${N.frame}, pc + 1)
78
+ return pc + 1
60
79
  end
61
80
 
62
81
  -- LOADNIL
63
82
  ${N.handlers}[${op(Opcode.LOADNIL)}] = function(${N.frame}, pc, instr)
64
83
  for i = instr[2], instr[3] do ${N.frame}.${N.R}[i] = nil end
65
- return ${N.dispatch}(${N.frame}, pc + 1)
84
+ return pc + 1
66
85
  end
67
86
 
68
87
  -- GETUPVAL
69
88
  ${N.handlers}[${op(Opcode.GETUPVAL)}] = function(${N.frame}, pc, instr)
70
89
  ${N.frame}.${N.R}[instr[2]] = ${N.frame}.${N.upvals}[instr[3] + 1]
71
- return ${N.dispatch}(${N.frame}, pc + 1)
90
+ return pc + 1
72
91
  end
73
92
 
74
93
  -- SETUPVAL
75
94
  ${N.handlers}[${op(Opcode.SETUPVAL)}] = function(${N.frame}, pc, instr)
76
95
  ${N.frame}.${N.upvals}[instr[3] + 1] = ${N.frame}.${N.R}[instr[2]]
77
- return ${N.dispatch}(${N.frame}, pc + 1)
96
+ return pc + 1
78
97
  end
79
98
 
80
99
  -- GETGLOBAL (b는 RK가 아니라 상수풀 순수 인덱스)
81
100
  ${N.handlers}[${op(Opcode.GETGLOBAL)}] = function(${N.frame}, pc, instr)
82
101
  ${N.frame}.${N.R}[instr[2]] = ${N.globals}[${N.frame}.${N.K}[instr[3] + 1]]
83
- return ${N.dispatch}(${N.frame}, pc + 1)
102
+ return pc + 1
84
103
  end
85
104
 
86
105
  -- SETGLOBAL
87
106
  ${N.handlers}[${op(Opcode.SETGLOBAL)}] = function(${N.frame}, pc, instr)
88
107
  ${N.globals}[${N.frame}.${N.K}[instr[3] + 1]] = ${N.frame}.${N.R}[instr[2]]
89
- return ${N.dispatch}(${N.frame}, pc + 1)
108
+ return pc + 1
90
109
  end
91
110
 
92
111
  -- GETTABLE
93
112
  ${N.handlers}[${op(Opcode.GETTABLE)}] = function(${N.frame}, pc, instr)
94
113
  ${N.frame}.${N.R}[instr[2]] = ${N.frame}.${N.R}[instr[3]][${N.rk}(${N.frame}, instr[4])]
95
- return ${N.dispatch}(${N.frame}, pc + 1)
114
+ return pc + 1
96
115
  end
97
116
 
98
117
  -- SETTABLE
99
118
  ${N.handlers}[${op(Opcode.SETTABLE)}] = function(${N.frame}, pc, instr)
100
119
  ${N.frame}.${N.R}[instr[2]][${N.rk}(${N.frame}, instr[3])] = ${N.rk}(${N.frame}, instr[4])
101
- return ${N.dispatch}(${N.frame}, pc + 1)
120
+ return pc + 1
102
121
  end
103
122
 
104
123
  -- NEWTABLE
105
124
  ${N.handlers}[${op(Opcode.NEWTABLE)}] = function(${N.frame}, pc, instr)
106
125
  ${N.frame}.${N.R}[instr[2]] = {}
107
- return ${N.dispatch}(${N.frame}, pc + 1)
126
+ return pc + 1
108
127
  end
109
128
 
110
129
  -- SELF (R[a] = R[b][key]; R[a+1] = R[b])
@@ -112,90 +131,90 @@ ${N.handlers}[${op(Opcode.SELF)}] = function(${N.frame}, pc, instr)
112
131
  local obj = ${N.frame}.${N.R}[instr[3]]
113
132
  ${N.frame}.${N.R}[instr[2]] = obj[${N.rk}(${N.frame}, instr[4])]
114
133
  ${N.frame}.${N.R}[instr[2] + 1] = obj
115
- return ${N.dispatch}(${N.frame}, pc + 1)
134
+ return pc + 1
116
135
  end
117
136
 
118
137
  -- ADD SUB MUL DIV MOD POW
119
138
  ${N.handlers}[${op(Opcode.ADD)}] = function(${N.frame}, pc, instr)
120
139
  ${N.frame}.${N.R}[instr[2]] = ${N.rk}(${N.frame}, instr[3]) + ${N.rk}(${N.frame}, instr[4])
121
- return ${N.dispatch}(${N.frame}, pc + 1)
140
+ return pc + 1
122
141
  end
123
142
  ${N.handlers}[${op(Opcode.SUB)}] = function(${N.frame}, pc, instr)
124
143
  ${N.frame}.${N.R}[instr[2]] = ${N.rk}(${N.frame}, instr[3]) - ${N.rk}(${N.frame}, instr[4])
125
- return ${N.dispatch}(${N.frame}, pc + 1)
144
+ return pc + 1
126
145
  end
127
146
  ${N.handlers}[${op(Opcode.MUL)}] = function(${N.frame}, pc, instr)
128
147
  ${N.frame}.${N.R}[instr[2]] = ${N.rk}(${N.frame}, instr[3]) * ${N.rk}(${N.frame}, instr[4])
129
- return ${N.dispatch}(${N.frame}, pc + 1)
148
+ return pc + 1
130
149
  end
131
150
  ${N.handlers}[${op(Opcode.DIV)}] = function(${N.frame}, pc, instr)
132
151
  ${N.frame}.${N.R}[instr[2]] = ${N.rk}(${N.frame}, instr[3]) / ${N.rk}(${N.frame}, instr[4])
133
- return ${N.dispatch}(${N.frame}, pc + 1)
152
+ return pc + 1
134
153
  end
135
154
  ${N.handlers}[${op(Opcode.MOD)}] = function(${N.frame}, pc, instr)
136
155
  ${N.frame}.${N.R}[instr[2]] = ${N.rk}(${N.frame}, instr[3]) % ${N.rk}(${N.frame}, instr[4])
137
- return ${N.dispatch}(${N.frame}, pc + 1)
156
+ return pc + 1
138
157
  end
139
158
  ${N.handlers}[${op(Opcode.POW)}] = function(${N.frame}, pc, instr)
140
159
  ${N.frame}.${N.R}[instr[2]] = ${N.rk}(${N.frame}, instr[3]) ^ ${N.rk}(${N.frame}, instr[4])
141
- return ${N.dispatch}(${N.frame}, pc + 1)
160
+ return pc + 1
142
161
  end
143
162
 
144
163
  -- IDIV (Luau 바닥 나눗셈)
145
164
  ${N.handlers}[${op(Opcode.IDIV)}] = function(${N.frame}, pc, instr)
146
165
  ${N.frame}.${N.R}[instr[2]] = ${N.rk}(${N.frame}, instr[3]) // ${N.rk}(${N.frame}, instr[4])
147
- return ${N.dispatch}(${N.frame}, pc + 1)
166
+ return pc + 1
148
167
  end
149
168
 
150
169
  -- CONCAT
151
170
  ${N.handlers}[${op(Opcode.CONCAT)}] = function(${N.frame}, pc, instr)
152
171
  ${N.frame}.${N.R}[instr[2]] = ${N.rk}(${N.frame}, instr[3]) .. ${N.rk}(${N.frame}, instr[4])
153
- return ${N.dispatch}(${N.frame}, pc + 1)
172
+ return pc + 1
154
173
  end
155
174
 
156
175
  -- UNM
157
176
  ${N.handlers}[${op(Opcode.UNM)}] = function(${N.frame}, pc, instr)
158
177
  ${N.frame}.${N.R}[instr[2]] = -${N.frame}.${N.R}[instr[3]]
159
- return ${N.dispatch}(${N.frame}, pc + 1)
178
+ return pc + 1
160
179
  end
161
180
 
162
181
  -- NOT
163
182
  ${N.handlers}[${op(Opcode.NOT)}] = function(${N.frame}, pc, instr)
164
183
  ${N.frame}.${N.R}[instr[2]] = not ${N.frame}.${N.R}[instr[3]]
165
- return ${N.dispatch}(${N.frame}, pc + 1)
184
+ return pc + 1
166
185
  end
167
186
 
168
187
  -- LEN
169
188
  ${N.handlers}[${op(Opcode.LEN)}] = function(${N.frame}, pc, instr)
170
189
  ${N.frame}.${N.R}[instr[2]] = #${N.frame}.${N.R}[instr[3]]
171
- return ${N.dispatch}(${N.frame}, pc + 1)
190
+ return pc + 1
172
191
  end
173
192
 
174
193
  -- JMP (pc += a)
175
194
  ${N.handlers}[${op(Opcode.JMP)}] = function(${N.frame}, pc, instr)
176
- return ${N.dispatch}(${N.frame}, pc + instr[2])
195
+ return pc + instr[2]
177
196
  end
178
197
 
179
198
  -- EQ LT LE (값 생성형 비교)
180
199
  ${N.handlers}[${op(Opcode.EQ)}] = function(${N.frame}, pc, instr)
181
200
  ${N.frame}.${N.R}[instr[2]] = ${N.rk}(${N.frame}, instr[3]) == ${N.rk}(${N.frame}, instr[4])
182
- return ${N.dispatch}(${N.frame}, pc + 1)
201
+ return pc + 1
183
202
  end
184
203
  ${N.handlers}[${op(Opcode.LT)}] = function(${N.frame}, pc, instr)
185
204
  ${N.frame}.${N.R}[instr[2]] = ${N.rk}(${N.frame}, instr[3]) < ${N.rk}(${N.frame}, instr[4])
186
- return ${N.dispatch}(${N.frame}, pc + 1)
205
+ return pc + 1
187
206
  end
188
207
  ${N.handlers}[${op(Opcode.LE)}] = function(${N.frame}, pc, instr)
189
208
  ${N.frame}.${N.R}[instr[2]] = ${N.rk}(${N.frame}, instr[3]) <= ${N.rk}(${N.frame}, instr[4])
190
- return ${N.dispatch}(${N.frame}, pc + 1)
209
+ return pc + 1
191
210
  end
192
211
 
193
212
  -- TEST (if (not R[a]) != (c!=0) then pc += 2 else pc += 1 — 보통 다음 명령은 JMP)
194
213
  ${N.handlers}[${op(Opcode.TEST)}] = function(${N.frame}, pc, instr)
195
214
  if (not ${N.frame}.${N.R}[instr[2]]) ~= (instr[4] ~= 0) then
196
- return ${N.dispatch}(${N.frame}, pc + 2)
215
+ return pc + 2
197
216
  end
198
- return ${N.dispatch}(${N.frame}, pc + 1)
217
+ return pc + 1
199
218
  end
200
219
 
201
220
  -- CALL
@@ -210,7 +229,13 @@ ${N.handlers}[${op(Opcode.CALL)}] = function(${N.frame}, pc, instr)
210
229
  end
211
230
  local args = table.create(nargs)
212
231
  for i = 1, nargs do args[i] = ${N.frame}.${N.R}[a + i] end
213
- local rets = table.pack(fn(table.unpack(args, 1, nargs)))
232
+ ${debug ? ` if type(fn) ~= "function" then
233
+ local mt = getmetatable(fn)
234
+ if not (mt and mt.__call) then
235
+ error(("[vmdebug] attempt to call a %s value: '%s' (source line %d)"):format(type(fn), tostring(instr[6]), tonumber(instr[5]) or -1), 0)
236
+ end
237
+ end
238
+ ` : ""} local rets = table.pack(fn(table.unpack(args, 1, nargs)))
214
239
  if c == 1 then
215
240
  -- discard
216
241
  elseif c == 0 then
@@ -220,23 +245,27 @@ ${N.handlers}[${op(Opcode.CALL)}] = function(${N.frame}, pc, instr)
220
245
  local want = c - 1
221
246
  for i = 1, want do ${N.frame}.${N.R}[a + i - 1] = rets[i] end
222
247
  end
223
- return ${N.dispatch}(${N.frame}, pc + 1)
248
+ return pc + 1
224
249
  end
225
250
 
226
251
  -- RETURN
252
+ -- 다른 핸들러는 "다음 pc(숫자)"를 반환하지만, RETURN만은 결과값을 table.pack으로 싸서
253
+ -- 테이블을 반환한다 — dispatch 루프는 반환값이 숫자가 아니면 이걸 함수 종료 신호로 보고
254
+ -- table.unpack(next, 1, next.n)으로 풀어 최종 반환한다. table.pack이 n을 채워 주므로
255
+ -- 중간 nil이 있어도 반환 개수가 정확히 보존된다.
227
256
  ${N.handlers}[${op(Opcode.RETURN)}] = function(${N.frame}, pc, instr)
228
257
  local a, b = instr[2], instr[3]
229
258
  if b == 0 then
230
- return table.unpack(${N.frame}.${N.R}, a, (${N.frame}.${N.multiTop} or (a + 1)) - 1)
259
+ return table.pack(table.unpack(${N.frame}.${N.R}, a, (${N.frame}.${N.multiTop} or (a + 1)) - 1))
231
260
  end
232
- return table.unpack(${N.frame}.${N.R}, a, a + b - 2)
261
+ return table.pack(table.unpack(${N.frame}.${N.R}, a, a + b - 2))
233
262
  end
234
263
 
235
264
  -- FORPREP
236
265
  ${N.handlers}[${op(Opcode.FORPREP)}] = function(${N.frame}, pc, instr)
237
266
  local a = instr[2]
238
267
  ${N.frame}.${N.R}[a] = ${N.frame}.${N.R}[a] - ${N.frame}.${N.R}[a + 2]
239
- return ${N.dispatch}(${N.frame}, pc + instr[3])
268
+ return pc + instr[3]
240
269
  end
241
270
 
242
271
  -- FORLOOP
@@ -252,9 +281,9 @@ ${N.handlers}[${op(Opcode.FORLOOP)}] = function(${N.frame}, pc, instr)
252
281
  end
253
282
  if ok then
254
283
  ${N.frame}.${N.R}[a + 3] = ${N.frame}.${N.R}[a]
255
- return ${N.dispatch}(${N.frame}, pc + instr[3])
284
+ return pc + instr[3]
256
285
  end
257
- return ${N.dispatch}(${N.frame}, pc + 1)
286
+ return pc + 1
258
287
  end
259
288
 
260
289
  -- CLOSURE
@@ -274,7 +303,7 @@ ${N.handlers}[${op(Opcode.CLOSURE)}] = function(${N.frame}, pc, instr)
274
303
  ${N.frame}.${N.R}[instr[2]] = function(...)
275
304
  return ${N.execute}(childProto, capturedUpvals, ...)
276
305
  end
277
- return ${N.dispatch}(${N.frame}, pc + 1 + n)
306
+ return pc + 1 + n
278
307
  end
279
308
 
280
309
  -- VARARG
@@ -286,7 +315,7 @@ ${N.handlers}[${op(Opcode.VARARG)}] = function(${N.frame}, pc, instr)
286
315
  if instr[3] == 0 then
287
316
  ${N.frame}.${N.multiTop} = instr[2] + count
288
317
  end
289
- return ${N.dispatch}(${N.frame}, pc + 1)
318
+ return pc + 1
290
319
  end
291
320
 
292
321
  -- SETLIST
@@ -299,7 +328,7 @@ ${N.handlers}[${op(Opcode.SETLIST)}] = function(${N.frame}, pc, instr)
299
328
  for i = 0, n - 1 do
300
329
  tbl[startIdx + i] = ${N.frame}.${N.R}[valuesBase + i]
301
330
  end
302
- return ${N.dispatch}(${N.frame}, pc + 1)
331
+ return pc + 1
303
332
  end
304
333
 
305
334
  function ${N.execute}(proto, upvals, ...)
@@ -26,16 +26,22 @@ function serializeConstValue(v: ConstValue): Expression {
26
26
  * 테이블을 바로 찾아낼 수 있기 때문. runtime.ts가 같은 names로 instr.<key>를 읽으므로
27
27
  * 반드시 컴파일 시점에 쓴 것과 동일한 VmNames 인스턴스를 넘겨야 한다.
28
28
  */
29
- function serializeInstrs(proto: Proto): Expression {
30
- const fields: TableField[] = proto.code.map((instr) =>
31
- positionalField(
32
- table([
33
- positionalField(vmNumberLiteral(instr.op as number)),
34
- positionalField(vmNumberLiteral(instr.a)),
35
- positionalField(vmNumberLiteral(instr.b)),
36
- positionalField(vmNumberLiteral(instr.c)),
37
- ]),
38
- ))
29
+ function serializeInstrs(proto: Proto, debug: boolean): Expression {
30
+ const fields: TableField[] = proto.code.map((instr) => {
31
+ const tuple = [
32
+ positionalField(vmNumberLiteral(instr.op as number)),
33
+ positionalField(vmNumberLiteral(instr.a)),
34
+ positionalField(vmNumberLiteral(instr.b)),
35
+ positionalField(vmNumberLiteral(instr.c)),
36
+ ]
37
+ if (debug) {
38
+ // [5] = 소스 줄(없으면 0), [6] = 호출 대상 이름(없으면 ""). 런타임이 non-callable을
39
+ // 잡았을 때 이 두 값으로 원본 위치를 찍는다. instr[1..4]는 그대로라 안 건드림.
40
+ tuple.push(positionalField(numberLiteral(instr.dbgLine ?? 0)))
41
+ tuple.push(positionalField(stringLiteral(instr.dbgName ?? "")))
42
+ }
43
+ return positionalField(table(tuple))
44
+ })
39
45
  return table(fields)
40
46
  }
41
47
 
@@ -52,15 +58,15 @@ function serializeUpvalDescs(proto: Proto, names: VmNames): Expression {
52
58
 
53
59
  /** Proto 하나를 { numParams=.., hasVarargs=.., maxRegs=.., code={...}, consts={...}, upvalDescs={...}, protos={...} }로
54
60
  * (필드 키는 전부 names에서 온 무작위 이름). */
55
- export function serializeProto(proto: Proto, names: VmNames): Expression {
61
+ export function serializeProto(proto: Proto, names: VmNames, debug = false): Expression {
56
62
  const fields: TableField[] = [
57
63
  namedField(names.numParams, vmNumberLiteral(proto.numParams)),
58
64
  namedField(names.hasVarargs, booleanLiteral(proto.hasVarargs)),
59
65
  namedField(names.maxRegs, vmNumberLiteral(proto.maxRegs)),
60
- namedField(names.code, serializeInstrs(proto)),
66
+ namedField(names.code, serializeInstrs(proto, debug)),
61
67
  namedField(names.consts, serializeConsts(proto.consts)),
62
68
  namedField(names.upvalDescs, serializeUpvalDescs(proto, names)),
63
- namedField(names.protos, table(proto.protos.map((p) => positionalField(serializeProto(p, names))))),
69
+ namedField(names.protos, table(proto.protos.map((p) => positionalField(serializeProto(p, names, debug))))),
64
70
  ]
65
71
  return table(fields)
66
72
  }