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.
Files changed (38) hide show
  1. package/.github/workflows/release.yml +56 -56
  2. package/dist/index.cjs +2186 -95
  3. package/dist/index.d.cts +34 -2
  4. package/dist/index.d.ts +34 -2
  5. package/dist/index.js +2186 -95
  6. package/generated/grammers.luau +13643 -0
  7. package/generated/tests.luau +5567 -0
  8. package/package.json +2 -2
  9. package/scripts/test.js +24 -8
  10. package/{scripts/example.luau → smoketest/grammers.luau} +1174 -1174
  11. package/smoketest/tests.luau +115 -0
  12. package/src/config.ts +2 -1
  13. package/src/passes/ConstantArray.ts +90 -89
  14. package/src/passes/EncryptNumbers.ts +122 -65
  15. package/src/passes/EncryptStrings.ts +162 -82
  16. package/src/passes/GlobalMapping.ts +193 -193
  17. package/src/passes/InsertJunk.ts +184 -184
  18. package/src/passes/Minify.ts +5 -5
  19. package/src/passes/NumbersToExpressions.ts +183 -2
  20. package/src/passes/RenameVariables.ts +2 -9
  21. package/src/passes/StringsToExpressions.ts +195 -6
  22. package/src/passes/StripTypes.ts +191 -191
  23. package/src/passes/Vmify.ts +87 -0
  24. package/src/passes/WrapInFunction.ts +31 -31
  25. package/src/passes/nodeFactory.ts +36 -1
  26. package/src/passes/vmify/chunk.ts +54 -0
  27. package/src/passes/vmify/compiler.ts +1026 -0
  28. package/src/passes/vmify/names.ts +72 -0
  29. package/src/passes/vmify/opcodes.ts +80 -0
  30. package/src/passes/vmify/registers.ts +63 -0
  31. package/src/passes/vmify/runtime.ts +334 -0
  32. package/src/passes/vmify/scope-walk.ts +175 -0
  33. package/src/passes/vmify/serialize.ts +68 -0
  34. package/src/passes/walk.ts +141 -43
  35. package/src/pipeline.ts +5 -6
  36. package/tsconfig.json +20 -20
  37. package/tsup.config.ts +9 -9
  38. package/generated/final.luau +0 -1029
@@ -0,0 +1,1026 @@
1
+ import type {
2
+ Program, Block, Statement, Expression, FunctionBody, Binding, BindingId, ScopeAnalysis,
3
+ CallExpression,
4
+ } from "luau-parser"
5
+ import { luauparser } from "luau-parser"
6
+ import { Opcode, RK, type Instr, type OpcodeMap } from "./opcodes"
7
+ import { type Proto, type ConstValue, createProto, internConst } from "./chunk"
8
+ import { RegisterAllocator } from "./registers"
9
+ import { buildEnclosingFunctionMap, ownerFunctionOf, type EnclosingFunctionMap, type FuncMarker } from "./scope-walk"
10
+
11
+ /** 박스({v = value}) 안의 값에 접근할 때 쓰는 고정 키. 상수 풀에 인턴됨. */
12
+ const BOX_FIELD = "v"
13
+
14
+ /** Roblox/Luau 표준 전역 중 최소한. 실제 사용 시 프로젝트에 맞게 채워야 함. */
15
+ export const DEFAULT_BUILTIN_GLOBALS = [
16
+ "game", "script", "workspace", "print", "warn", "error", "pairs", "ipairs",
17
+ "pcall", "xpcall", "type", "typeof", "tostring", "tonumber", "select", "unpack",
18
+ "table", "string", "math", "os", "task", "Instance", "Vector3", "CFrame", "Color3",
19
+ "require", "setmetatable", "getmetatable", "rawget", "rawset", "rawequal", "next",
20
+ ]
21
+
22
+ interface LocalSlot {
23
+ reg: number
24
+ boxed: boolean
25
+ }
26
+
27
+ interface FuncState {
28
+ proto: Proto
29
+ parent?: FuncState
30
+ marker: FuncMarker
31
+ regs: RegisterAllocator
32
+ localSlots: Map<BindingId, LocalSlot>
33
+ upvalIndexByBinding: Map<BindingId, number>
34
+ breakPatchStack: number[][]
35
+ continuePatchStack: number[][]
36
+ }
37
+
38
+ export class VmCompiler {
39
+ private analysis: ScopeAnalysis
40
+ private enclosing: EnclosingFunctionMap
41
+ private captured = new Set<BindingId>()
42
+ private protoIdCounter = 0
43
+ private declToBinding = new Map<object, BindingId>()
44
+
45
+ constructor(
46
+ private program: Program,
47
+ builtinGlobals: readonly string[] = DEFAULT_BUILTIN_GLOBALS,
48
+ private opcodeMap?: OpcodeMap,
49
+ ) {
50
+ this.analysis = luauparser.analyzeScopes(program, { builtinGlobals })
51
+ this.enclosing = buildEnclosingFunctionMap(program)
52
+ for (const [id, binding] of this.analysis.bindings) {
53
+ if (binding.declarationNode) this.declToBinding.set(binding.declarationNode, id)
54
+ }
55
+ this.computeCaptured()
56
+ }
57
+
58
+ private computeCaptured(): void {
59
+ for (const binding of this.analysis.bindings.values()) {
60
+ if (binding.kind === "global") continue
61
+ if (!binding.declarationNode) continue
62
+ const ownerFn = ownerFunctionOf(this.enclosing, binding.declarationNode)
63
+ for (const ref of binding.references) {
64
+ const refFn = ownerFunctionOf(this.enclosing, ref)
65
+ if (refFn !== ownerFn) {
66
+ this.captured.add(binding.id)
67
+ break
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ compile(): Proto {
74
+ const top = this.newFuncState(undefined, this.enclosing.topMarker)
75
+ top.proto.hasVarargs = true
76
+ this.compileBlock(this.program.body, top)
77
+ this.emit(top, Opcode.RETURN, 0, 1, 0)
78
+ top.proto.maxRegs = top.regs.maxUsed
79
+ return top.proto
80
+ }
81
+
82
+ private newFuncState(parent: FuncState | undefined, marker: FuncMarker): FuncState {
83
+ return {
84
+ proto: createProto(this.protoIdCounter++),
85
+ parent,
86
+ marker,
87
+ regs: new RegisterAllocator(),
88
+ localSlots: new Map(),
89
+ upvalIndexByBinding: new Map(),
90
+ breakPatchStack: [],
91
+ continuePatchStack: [],
92
+ }
93
+ }
94
+
95
+ private emit(state: FuncState, op: Opcode, a: number, b: number, c: number, comment?: string): number {
96
+ // opcodeMap이 주어지면(runVmify 경유 실행) 논리 opcode 번호를 이 빌드의 무작위
97
+ // 물리 번호로 바꿔서 저장한다. 직접 VmCompiler를 쓰는 테스트/디버깅 코드가
98
+ // opcodeMap 없이 호출할 수도 있으니 없으면 항등(그대로) 매핑으로 폴백.
99
+ const physicalOp = this.opcodeMap ? this.opcodeMap[op] : op
100
+ state.proto.code.push({ op: physicalOp, a, b, c, comment } as Instr)
101
+ return state.proto.code.length - 1
102
+ }
103
+
104
+ private konst(state: FuncState, value: ConstValue): number {
105
+ return internConst(state.proto, value)
106
+ }
107
+
108
+ private bindingIdOf(node: object): BindingId | undefined {
109
+ return (this.analysis.bindingOf as Map<object, BindingId>).get(node)
110
+ }
111
+
112
+ private getOrCreateUpval(state: FuncState, bindingId: BindingId): number {
113
+ const existing = state.upvalIndexByBinding.get(bindingId)
114
+ if (existing !== undefined) return existing
115
+ const parent = state.parent
116
+ if (!parent) throw new Error("vmify: binding not found in any enclosing function (compiler bug)")
117
+ const parentLocal = parent.localSlots.get(bindingId)
118
+ const idx = state.proto.upvalDescs.length
119
+ if (parentLocal) {
120
+ state.proto.upvalDescs.push({ kind: "local", index: parentLocal.reg })
121
+ } else {
122
+ const parentUpvalIdx = this.getOrCreateUpval(parent, bindingId)
123
+ state.proto.upvalDescs.push({ kind: "upval", index: parentUpvalIdx })
124
+ }
125
+ state.upvalIndexByBinding.set(bindingId, idx)
126
+ return idx
127
+ }
128
+
129
+ private bindingIsLocalSomewhereUp(state: FuncState, bindingId: BindingId): boolean {
130
+ let s: FuncState | undefined = state
131
+ while (s) {
132
+ if (s.localSlots.has(bindingId)) return true
133
+ s = s.parent
134
+ }
135
+ return false
136
+ }
137
+
138
+ private declareLocalBinding(state: FuncState, bindingId: BindingId): number {
139
+ const reg = state.regs.declareLocal()
140
+ state.localSlots.set(bindingId, { reg, boxed: this.captured.has(bindingId) })
141
+ if (this.captured.has(bindingId)) this.emitNewBox(state, reg)
142
+ return reg
143
+ }
144
+
145
+ private emitNewBox(state: FuncState, reg: number): void {
146
+ this.emit(state, Opcode.NEWTABLE, reg, 0, 0, "box")
147
+ }
148
+
149
+ private emitLoadBinding(state: FuncState, bindingId: BindingId, dest: number): void {
150
+ const local = state.localSlots.get(bindingId)
151
+ if (local) {
152
+ if (local.boxed) {
153
+ const k = this.konst(state, BOX_FIELD)
154
+ this.emit(state, Opcode.GETTABLE, dest, local.reg, RK(k, true))
155
+ } else if (dest !== local.reg) {
156
+ this.emit(state, Opcode.MOVE, dest, local.reg, 0)
157
+ }
158
+ return
159
+ }
160
+ if (this.bindingIsLocalSomewhereUp(state, bindingId)) {
161
+ const idx = this.getOrCreateUpval(state, bindingId)
162
+ this.emit(state, Opcode.GETUPVAL, dest, idx, 0)
163
+ const k = this.konst(state, BOX_FIELD)
164
+ this.emit(state, Opcode.GETTABLE, dest, dest, RK(k, true))
165
+ return
166
+ }
167
+ const binding = this.analysis.bindings.get(bindingId) as Binding
168
+ const nameIdx = this.konst(state, binding.name)
169
+ this.emit(state, Opcode.GETGLOBAL, dest, nameIdx, 0)
170
+ }
171
+
172
+ private emitStoreBinding(state: FuncState, bindingId: BindingId, src: number): void {
173
+ const local = state.localSlots.get(bindingId)
174
+ if (local) {
175
+ if (local.boxed) {
176
+ const k = this.konst(state, BOX_FIELD)
177
+ this.emit(state, Opcode.SETTABLE, local.reg, RK(k, true), src)
178
+ } else if (src !== local.reg) {
179
+ this.emit(state, Opcode.MOVE, local.reg, src, 0)
180
+ }
181
+ return
182
+ }
183
+ if (this.bindingIsLocalSomewhereUp(state, bindingId)) {
184
+ const idx = this.getOrCreateUpval(state, bindingId)
185
+ const tmp = state.regs.allocTemp()
186
+ this.emit(state, Opcode.GETUPVAL, tmp, idx, 0)
187
+ const k = this.konst(state, BOX_FIELD)
188
+ this.emit(state, Opcode.SETTABLE, tmp, RK(k, true), src)
189
+ state.regs.freeTemp(tmp)
190
+ return
191
+ }
192
+ const binding = this.analysis.bindings.get(bindingId) as Binding
193
+ const nameIdx = this.konst(state, binding.name)
194
+ this.emit(state, Opcode.SETGLOBAL, src, nameIdx, 0)
195
+ }
196
+
197
+ private compileBlock(block: Block, state: FuncState): void {
198
+ const saved = state.regs.saveLocalCount()
199
+ for (const stmt of block.statements) this.compileStatement(stmt, state)
200
+ state.regs.releaseLocalsTo(saved)
201
+ }
202
+
203
+ private compileStatement(stmt: Statement, state: FuncState): void {
204
+ switch (stmt.type) {
205
+ case "LocalStatement": {
206
+ // 순서 중요: 로컬(및 캡처된 것의 박스)을 "먼저" 선언해서 레지스터를 확정한 뒤에
207
+ // 초기화식을 컴파일해야 한다. 반대로 하면(예전 버그) 초기화값을 임시 레지스터에
208
+ // 계산해놓고 나서 declareLocalBinding이 캡처 여부를 보고 "같은" 레지스터에
209
+ // NEWTABLE(박스)을 찍어버려 방금 계산한 값이 통째로 날아간다.
210
+ // bindingId 해석 자체는 analyzeScopes가 정적으로 끝내놓기 때문에
211
+ // (`local x = x`의 우변은 바깥쪽 x를 정확히 가리킴) 레지스터를 먼저 확정해도
212
+ // 셰도잉 문제는 생기지 않는다.
213
+ const bindingIds = stmt.names.map((n) => this.bindingIdOfDecl(n))
214
+ for (const id of bindingIds) this.declareLocalBinding(state, id)
215
+ const boundary = state.regs.top()
216
+ // 초기화식 목록: 마지막 식이 Call/MethodCall/Vararg면 남은 이름 개수만큼
217
+ // 확장하고(`local a, b = pair()`), 그 외엔 각자 1개씩 채우고 모자란 이름은 nil.
218
+ let filledUpTo = 0 // boundary 기준으로 이미 값이 채워진 슬롯 수(패딩 시작점)
219
+ if (stmt.init.length > 0) {
220
+ for (let i = 0; i < stmt.init.length - 1; i++) {
221
+ this.compileExprTo(stmt.init[i], state, boundary + i)
222
+ }
223
+ const lastIdx = stmt.init.length - 1
224
+ const remaining = Math.max(bindingIds.length - lastIdx, 1)
225
+ this.compileExprMultiInto(stmt.init[lastIdx], state, boundary + lastIdx, remaining)
226
+ filledUpTo = lastIdx + remaining // 마지막 식이 채운 슬롯까지 전부 포함
227
+ }
228
+ for (let i = filledUpTo; i < bindingIds.length; i++) {
229
+ this.emit(state, Opcode.LOADNIL, boundary + i, boundary + i, 0)
230
+ }
231
+ for (let i = 0; i < bindingIds.length; i++) {
232
+ this.emitStoreBinding(state, bindingIds[i], boundary + i)
233
+ }
234
+ state.regs.freeTemp(boundary)
235
+ return
236
+ }
237
+ case "AssignmentStatement": {
238
+ const valueRegs = stmt.values.map((e) => this.compileExpr(e, state))
239
+ stmt.targets.forEach((target, i) => {
240
+ const srcReg = valueRegs[i] ?? valueRegs[valueRegs.length - 1]
241
+ this.compileAssignTarget(target, srcReg, state)
242
+ })
243
+ return
244
+ }
245
+ case "CompoundAssignmentStatement":
246
+ this.compileCompoundAssignment(stmt, state)
247
+ return
248
+ case "CallStatement":
249
+ this.compileExpr(stmt.expression, state, true)
250
+ return
251
+ case "IfStatement":
252
+ this.compileIf(stmt, state)
253
+ return
254
+ case "WhileStatement":
255
+ this.compileWhile(stmt, state)
256
+ return
257
+ case "RepeatStatement":
258
+ this.compileRepeat(stmt, state)
259
+ return
260
+ case "NumericForStatement":
261
+ this.compileNumericFor(stmt, state)
262
+ return
263
+ case "GenericForStatement":
264
+ this.compileGenericFor(stmt, state)
265
+ return
266
+ case "DoStatement":
267
+ this.compileBlock(stmt.body, state)
268
+ return
269
+ case "ReturnStatement": {
270
+ if (stmt.arguments.length === 0) {
271
+ this.emit(state, Opcode.RETURN, state.regs.top(), 1, 0)
272
+ return
273
+ }
274
+ // `return f()` / `return ...` (인자가 딱 하나뿐이고 다중값 가능) — 결과 개수를
275
+ // 컴파일 타임에 모르니 열어서 그대로 전파(RETURN b=0, 런타임 multiTop 기준).
276
+ if (stmt.arguments.length === 1 && this.isMultiValueExpr(stmt.arguments[0])) {
277
+ const base = state.regs.top()
278
+ this.compileExprOpenInto(stmt.arguments[0], state, base)
279
+ this.emit(state, Opcode.RETURN, base, 0, 0)
280
+ state.regs.freeTemp(base)
281
+ return
282
+ }
283
+ // 여러 개 있으면 마지막 것만 열어서 전파, 나머지는 고정 1개씩.
284
+ const boundary = state.regs.top()
285
+ for (let i = 0; i < stmt.arguments.length - 1; i++) {
286
+ this.compileExprTo(stmt.arguments[i], state, boundary + i)
287
+ }
288
+ const lastIdx = stmt.arguments.length - 1
289
+ if (this.isMultiValueExpr(stmt.arguments[lastIdx])) {
290
+ this.compileExprOpenInto(stmt.arguments[lastIdx], state, boundary + lastIdx)
291
+ this.emit(state, Opcode.RETURN, boundary, 0, 0)
292
+ } else {
293
+ this.compileExprTo(stmt.arguments[lastIdx], state, boundary + lastIdx)
294
+ this.emit(state, Opcode.RETURN, boundary, stmt.arguments.length + 1, 0)
295
+ }
296
+ state.regs.freeTemp(boundary)
297
+ return
298
+ }
299
+ case "LocalFunctionStatement": {
300
+ // 캡처되는 경우 CLOSURE를 박스 레지스터에 직접 쓰면 안 된다 — 그 순간
301
+ // 박스(테이블)가 함수 값으로 덮어써져서 사라진다. 임시 레지스터에 만든 뒤
302
+ // emitStoreBinding으로 박스 안에 넣어야 함(재귀 함수 자기 참조도 이 순서로 지원됨).
303
+ const bindingId = this.bindingIdOfDecl(stmt.name)
304
+ const reg = this.declareLocalBinding(state, bindingId)
305
+ const protoIdx = this.compileFunctionBody(stmt.func, state)
306
+ const boxed = state.localSlots.get(bindingId)!.boxed
307
+ if (boxed) {
308
+ const tmp = state.regs.allocTemp()
309
+ this.emitClosure(state, tmp, protoIdx)
310
+ this.emitStoreBinding(state, bindingId, tmp)
311
+ state.regs.freeTemp(tmp)
312
+ } else {
313
+ this.emitClosure(state, reg, protoIdx)
314
+ }
315
+ return
316
+ }
317
+ case "FunctionDeclarationStatement":
318
+ this.compileFunctionDeclaration(stmt, state)
319
+ return
320
+ case "BreakStatement": {
321
+ if (state.breakPatchStack.length === 0) throw new Error("vmify: break outside loop")
322
+ const idx = this.emit(state, Opcode.JMP, 0, 0, 0, "break")
323
+ state.breakPatchStack[state.breakPatchStack.length - 1].push(idx)
324
+ return
325
+ }
326
+ case "ContinueStatement": {
327
+ if (state.continuePatchStack.length === 0) throw new Error("vmify: continue outside loop")
328
+ const idx = this.emit(state, Opcode.JMP, 0, 0, 0, "continue")
329
+ state.continuePatchStack[state.continuePatchStack.length - 1].push(idx)
330
+ return
331
+ }
332
+ case "TypeAliasStatement":
333
+ case "ExportTypeAliasStatement":
334
+ return
335
+ default: {
336
+ const _exhaustive: never = stmt
337
+ throw new Error(`vmify: unhandled statement ${(_exhaustive as Statement).type}`)
338
+ }
339
+ }
340
+ }
341
+
342
+ private compileAssignTarget(target: Expression, srcReg: number, state: FuncState): void {
343
+ if (target.type === "Identifier") {
344
+ const bindingId = this.bindingIdOf(target)
345
+ if (bindingId === undefined) throw new Error("vmify: unresolved identifier target")
346
+ this.emitStoreBinding(state, bindingId, srcReg)
347
+ return
348
+ }
349
+ if (target.type === "MemberExpression") {
350
+ const objReg = this.compileExpr(target.object, state)
351
+ const k = this.konst(state, target.property.name)
352
+ this.emit(state, Opcode.SETTABLE, objReg, RK(k, true), srcReg)
353
+ state.regs.freeTemp(objReg)
354
+ return
355
+ }
356
+ if (target.type === "IndexExpression") {
357
+ const objReg = this.compileExpr(target.object, state)
358
+ const keyReg = this.compileExpr(target.index, state)
359
+ this.emit(state, Opcode.SETTABLE, objReg, keyReg, srcReg)
360
+ state.regs.freeTemp(objReg)
361
+ return
362
+ }
363
+ throw new Error(`vmify: unsupported assignment target ${target.type}`)
364
+ }
365
+
366
+ private compileCompoundAssignment(
367
+ stmt: Extract<Statement, { type: "CompoundAssignmentStatement" }>,
368
+ state: FuncState,
369
+ ): void {
370
+ const opMap: Partial<Record<string, Opcode>> = {
371
+ "+=": Opcode.ADD, "-=": Opcode.SUB, "*=": Opcode.MUL, "/=": Opcode.DIV,
372
+ "//=": Opcode.IDIV, "%=": Opcode.MOD, "^=": Opcode.POW, "..=": Opcode.CONCAT,
373
+ }
374
+ const op = opMap[stmt.operator]
375
+ if (!op) throw new Error(`vmify: unsupported compound operator ${stmt.operator}`)
376
+
377
+ const target = stmt.target
378
+ if (target.type === "Identifier") {
379
+ const bindingId = this.bindingIdOf(target)
380
+ if (bindingId === undefined) throw new Error("vmify: unresolved identifier target")
381
+ const cur = state.regs.allocTemp()
382
+ this.emitLoadBinding(state, bindingId, cur)
383
+ const rhs = this.compileExpr(stmt.value, state)
384
+ this.emit(state, op, cur, cur, rhs)
385
+ state.regs.freeTemp(rhs)
386
+ this.emitStoreBinding(state, bindingId, cur)
387
+ state.regs.freeTemp(cur)
388
+ return
389
+ }
390
+ if (target.type === "MemberExpression") {
391
+ const objReg = this.compileExpr(target.object, state)
392
+ const k = this.konst(state, target.property.name)
393
+ const cur = state.regs.allocTemp()
394
+ this.emit(state, Opcode.GETTABLE, cur, objReg, RK(k, true))
395
+ const rhs = this.compileExpr(stmt.value, state)
396
+ this.emit(state, op, cur, cur, rhs)
397
+ this.emit(state, Opcode.SETTABLE, objReg, RK(k, true), cur)
398
+ state.regs.freeTemp(objReg)
399
+ return
400
+ }
401
+ if (target.type === "IndexExpression") {
402
+ const objReg = this.compileExpr(target.object, state)
403
+ const keyReg = this.compileExpr(target.index, state)
404
+ const cur = state.regs.allocTemp()
405
+ this.emit(state, Opcode.GETTABLE, cur, objReg, keyReg)
406
+ const rhs = this.compileExpr(stmt.value, state)
407
+ this.emit(state, op, cur, cur, rhs)
408
+ this.emit(state, Opcode.SETTABLE, objReg, keyReg, cur)
409
+ state.regs.freeTemp(objReg)
410
+ return
411
+ }
412
+ throw new Error(`vmify: unsupported compound assignment target ${target.type}`)
413
+ }
414
+
415
+ private compileFunctionDeclaration(
416
+ stmt: Extract<Statement, { type: "FunctionDeclarationStatement" }>,
417
+ state: FuncState,
418
+ ): void {
419
+ const protoIdx = this.compileFunctionBody(stmt.func, state)
420
+ const fnReg = state.regs.allocTemp()
421
+ this.emitClosure(state, fnReg, protoIdx)
422
+
423
+ const target = stmt.target
424
+ if (target.path.length === 0 && !target.method) {
425
+ const bindingId = this.bindingIdOf(target.base)
426
+ if (bindingId === undefined) throw new Error("vmify: unresolved function name")
427
+ this.emitStoreBinding(state, bindingId, fnReg)
428
+ state.regs.freeTemp(fnReg)
429
+ return
430
+ }
431
+
432
+ const baseBindingId = this.bindingIdOf(target.base)
433
+ if (baseBindingId === undefined) throw new Error("vmify: unresolved function name base")
434
+ let objReg = state.regs.allocTemp()
435
+ this.emitLoadBinding(state, baseBindingId, objReg)
436
+
437
+ const navigateSegments = target.method ? target.path : target.path.slice(0, -1)
438
+ for (const seg of navigateSegments) {
439
+ const k = this.konst(state, seg.name)
440
+ const next = state.regs.allocTemp()
441
+ this.emit(state, Opcode.GETTABLE, next, objReg, RK(k, true))
442
+ objReg = next
443
+ }
444
+ const finalKeyName = target.method ? target.method.name : target.path[target.path.length - 1].name
445
+ const k = this.konst(state, finalKeyName)
446
+ this.emit(state, Opcode.SETTABLE, objReg, RK(k, true), fnReg)
447
+ state.regs.freeTemp(fnReg)
448
+ }
449
+
450
+ private compileIf(stmt: Extract<Statement, { type: "IfStatement" }>, state: FuncState): void {
451
+ const endPatches: number[] = []
452
+ for (const clause of stmt.clauses) {
453
+ const condReg = this.compileExpr(clause.condition, state)
454
+ this.emit(state, Opcode.TEST, condReg, 0, 1) // skip next(jump-away) when truthy
455
+ const jmpOverBody = this.emit(state, Opcode.JMP, 0, 0, 0, "skip-then")
456
+ state.regs.freeTemp(condReg)
457
+ this.compileBlock(clause.body, state)
458
+ const jmpToEnd = this.emit(state, Opcode.JMP, 0, 0, 0, "then->end")
459
+ endPatches.push(jmpToEnd)
460
+ this.patchJump(state, jmpOverBody, state.proto.code.length)
461
+ }
462
+ if (stmt.alternate) this.compileBlock(stmt.alternate, state)
463
+ for (const p of endPatches) this.patchJump(state, p, state.proto.code.length)
464
+ }
465
+
466
+ private compileWhile(stmt: Extract<Statement, { type: "WhileStatement" }>, state: FuncState): void {
467
+ const loopStart = state.proto.code.length
468
+ const condReg = this.compileExpr(stmt.condition, state)
469
+ this.emit(state, Opcode.TEST, condReg, 0, 1) // skip next(jump-away) when truthy
470
+ const exitJmp = this.emit(state, Opcode.JMP, 0, 0, 0, "while-exit")
471
+ state.regs.freeTemp(condReg)
472
+ state.breakPatchStack.push([])
473
+ state.continuePatchStack.push([])
474
+ this.compileBlock(stmt.body, state)
475
+ this.emit(state, Opcode.JMP, loopStart - state.proto.code.length, 0, 0, "while-back")
476
+ const target = state.proto.code.length
477
+ this.patchJump(state, exitJmp, target)
478
+ for (const b of state.breakPatchStack.pop()!) this.patchJump(state, b, target)
479
+ for (const c of state.continuePatchStack.pop()!) this.patchJump(state, c, loopStart)
480
+ }
481
+
482
+ private compileRepeat(stmt: Extract<Statement, { type: "RepeatStatement" }>, state: FuncState): void {
483
+ const loopStart = state.proto.code.length
484
+ state.breakPatchStack.push([])
485
+ state.continuePatchStack.push([])
486
+ const saved = state.regs.saveLocalCount()
487
+ for (const s of stmt.body.statements) this.compileStatement(s, state)
488
+ const condCheckStart = state.proto.code.length
489
+ const condReg = this.compileExpr(stmt.condition, state)
490
+ this.emit(state, Opcode.TEST, condReg, 0, 1) // skip next(jump-away) when truthy
491
+ const jmpBack = this.emit(state, Opcode.JMP, 0, 0, 0, "repeat-back")
492
+ state.regs.freeTemp(condReg)
493
+ this.patchJump(state, jmpBack, loopStart)
494
+ state.regs.releaseLocalsTo(saved)
495
+ const endIdx = state.proto.code.length
496
+ for (const b of state.breakPatchStack.pop()!) this.patchJump(state, b, endIdx)
497
+ for (const c of state.continuePatchStack.pop()!) this.patchJump(state, c, condCheckStart)
498
+ }
499
+
500
+ private compileNumericFor(stmt: Extract<Statement, { type: "NumericForStatement" }>, state: FuncState): void {
501
+ const saved = state.regs.saveLocalCount()
502
+ const base = state.regs.declareLocal()
503
+ state.regs.declareLocal()
504
+ state.regs.declareLocal()
505
+ const cursor = state.regs.declareLocal()
506
+
507
+ const startTmp = this.compileExpr(stmt.start, state)
508
+ this.emit(state, Opcode.MOVE, base, startTmp, 0)
509
+ state.regs.freeTemp(startTmp)
510
+
511
+ const endTmp = this.compileExpr(stmt.end, state)
512
+ this.emit(state, Opcode.MOVE, base + 1, endTmp, 0)
513
+ state.regs.freeTemp(endTmp)
514
+
515
+ const stepTmp = stmt.step ? this.compileExpr(stmt.step, state) : this.loadConst(state, 1)
516
+ this.emit(state, Opcode.MOVE, base + 2, stepTmp, 0)
517
+ state.regs.freeTemp(stepTmp)
518
+
519
+ const prep = this.emit(state, Opcode.FORPREP, base, 0, 0, "forprep")
520
+ const loopBodyStart = state.proto.code.length
521
+
522
+ const bindingId = this.bindingIdOfDecl(stmt.variable)
523
+ const captured = this.captured.has(bindingId)
524
+ let varReg: number
525
+ if (captured) {
526
+ varReg = state.regs.declareLocal()
527
+ this.emitNewBox(state, varReg)
528
+ const k = this.konst(state, BOX_FIELD)
529
+ this.emit(state, Opcode.SETTABLE, varReg, RK(k, true), cursor)
530
+ } else {
531
+ varReg = cursor
532
+ }
533
+ state.localSlots.set(bindingId, { reg: varReg, boxed: captured })
534
+
535
+ state.breakPatchStack.push([])
536
+ state.continuePatchStack.push([])
537
+ this.compileBlock(stmt.body, state)
538
+ const loopInstr = this.emit(state, Opcode.FORLOOP, base, 0, 0, "forloop")
539
+ this.patchJumpField(state, prep, loopInstr, "b")
540
+ this.patchJumpField(state, loopInstr, loopBodyStart, "b")
541
+ const endIdx = state.proto.code.length
542
+ for (const b of state.breakPatchStack.pop()!) this.patchJump(state, b, endIdx)
543
+ for (const c of state.continuePatchStack.pop()!) this.patchJump(state, c, loopInstr)
544
+
545
+ state.regs.releaseLocalsTo(saved)
546
+ }
547
+
548
+ private compileGenericFor(stmt: Extract<Statement, { type: "GenericForStatement" }>, state: FuncState): void {
549
+ const saved = state.regs.saveLocalCount()
550
+ const base = state.regs.declareLocal()
551
+ state.regs.declareLocal()
552
+ state.regs.declareLocal()
553
+
554
+ const iterators = stmt.iterators
555
+ if (iterators.length === 1 && iterators[0].type === "CallExpression") {
556
+ const firstReg = this.compileCallMultiInto(iterators[0] as CallExpression, state, 3)
557
+ for (let i = 0; i < 3; i++) this.emit(state, Opcode.MOVE, base + i, firstReg + i, 0)
558
+ state.regs.freeTemp(firstReg)
559
+ } else {
560
+ for (let i = 0; i < 3; i++) {
561
+ if (i < iterators.length) {
562
+ const r = this.compileExpr(iterators[i], state)
563
+ this.emit(state, Opcode.MOVE, base + i, r, 0)
564
+ state.regs.freeTemp(r)
565
+ } else {
566
+ this.emit(state, Opcode.LOADNIL, base + i, base + i, 0)
567
+ }
568
+ }
569
+ }
570
+
571
+ const loopStart = state.proto.code.length
572
+ const nVars = stmt.variables.length
573
+ const callBase = state.regs.top()
574
+ const fnSlot = state.regs.allocTemp()
575
+ const stateSlot = state.regs.allocTemp()
576
+ const ctrlSlot = state.regs.allocTemp()
577
+ this.emit(state, Opcode.MOVE, fnSlot, base, 0)
578
+ this.emit(state, Opcode.MOVE, stateSlot, base + 1, 0)
579
+ this.emit(state, Opcode.MOVE, ctrlSlot, base + 2, 0)
580
+ this.emit(state, Opcode.CALL, fnSlot, 3, nVars + 1)
581
+ state.regs.freeTemp(callBase)
582
+
583
+ const resultRegs: number[] = []
584
+ for (let i = 0; i < nVars; i++) resultRegs.push(state.regs.declareLocal())
585
+ const firstResult = resultRegs[0]
586
+
587
+ this.emit(state, Opcode.TEST, firstResult, 0, 1) // skip next(jump-away) when truthy
588
+ const exitJmp = this.emit(state, Opcode.JMP, 0, 0, 0, "generic-for-exit")
589
+ this.emit(state, Opcode.MOVE, base + 2, firstResult, 0)
590
+
591
+ for (let i = 0; i < nVars; i++) {
592
+ const bindingId = this.bindingIdOfDecl(stmt.variables[i])
593
+ if (this.captured.has(bindingId)) {
594
+ const boxReg = state.regs.declareLocal()
595
+ state.localSlots.set(bindingId, { reg: boxReg, boxed: true })
596
+ this.emitNewBox(state, boxReg)
597
+ const k = this.konst(state, BOX_FIELD)
598
+ this.emit(state, Opcode.SETTABLE, boxReg, RK(k, true), resultRegs[i])
599
+ } else {
600
+ state.localSlots.set(bindingId, { reg: resultRegs[i], boxed: false })
601
+ }
602
+ }
603
+
604
+ state.breakPatchStack.push([])
605
+ state.continuePatchStack.push([])
606
+ this.compileBlock(stmt.body, state)
607
+ this.emit(state, Opcode.JMP, loopStart - state.proto.code.length, 0, 0, "generic-for-back")
608
+ const endIdx = state.proto.code.length
609
+ this.patchJump(state, exitJmp, endIdx)
610
+ for (const b of state.breakPatchStack.pop()!) this.patchJump(state, b, endIdx)
611
+ for (const c of state.continuePatchStack.pop()!) this.patchJump(state, c, loopStart)
612
+
613
+ state.regs.releaseLocalsTo(saved)
614
+ }
615
+
616
+ private compileCallMultiInto(callExpr: CallExpression, state: FuncState, wantCount: number): number {
617
+ // CallExpression과 동일한 이유로 top()을 미리 캐싱하지 않고 callee의 반환값을 base로 씀.
618
+ const base = this.compileExpr(callExpr.callee, state)
619
+ this.compileArgsContiguous(callExpr.arguments, state, base + 1)
620
+ this.emit(state, Opcode.CALL, base, callExpr.arguments.length + 1, wantCount + 1)
621
+ return base
622
+ }
623
+
624
+ private loadConst(state: FuncState, value: ConstValue): number {
625
+ const reg = state.regs.allocTemp()
626
+ const k = this.konst(state, value)
627
+ this.emit(state, Opcode.LOADK, reg, k, 0)
628
+ return reg
629
+ }
630
+
631
+ private patchJump(state: FuncState, instrIdx: number, targetIdx: number): void {
632
+ this.patchJumpField(state, instrIdx, targetIdx, "a")
633
+ }
634
+
635
+ private patchJumpField(state: FuncState, instrIdx: number, targetIdx: number, field: "a" | "b"): void {
636
+ state.proto.code[instrIdx][field] = targetIdx - instrIdx
637
+ }
638
+
639
+ private bindingIdOfDecl(node: object): BindingId {
640
+ const id = this.declToBinding.get(node)
641
+ if (id === undefined) throw new Error("vmify: could not resolve declaration binding (compiler bug)")
642
+ return id
643
+ }
644
+
645
+ /** expr을 컴파일해서 결과가 정확히 target 레지스터에 오도록 강제(필요하면 MOVE 한 번 추가). */
646
+ private compileExprTo(expr: Expression, state: FuncState, target: number): void {
647
+ const r = this.compileExpr(expr, state)
648
+ if (r !== target) this.emit(state, Opcode.MOVE, target, r, 0)
649
+ state.regs.freeTemp(target + 1)
650
+ }
651
+
652
+ /**
653
+ * CALL/RETURN처럼 "레지스터 base부터 연속으로 n개"를 요구하는 명령을 위해, 인자 표현식
654
+ * 목록을 base, base+1, base+2 ...에 강제로 배치한다. 단순히 순서대로 compileExpr만
655
+ * 호출하면 개별 인자는 각자 "자기 자신의 결과+1"이 다음 top이 되는 게 맞지만, 그 인자가
656
+ * 복합 표현식(예: 이항연산 `i > 2`)이라 내부에서 스크래치 레지스터를 여러 개 쓰면 다음
657
+ * 인자와의 사이에 빈틈이 생겨 CALL/RETURN이 잘못된 레지스터를 읽게 된다 — 그래서 매
658
+ * 인자마다 목표 레지스터를 명시하고 필요하면 MOVE로 맞춰준다.
659
+ */
660
+ private compileArgsContiguous(exprs: Expression[], state: FuncState, base: number): void {
661
+ for (let i = 0; i < exprs.length; i++) this.compileExprTo(exprs[i], state, base + i)
662
+ }
663
+
664
+ /** Call/MethodCall/Vararg처럼 "여러 값"을 낼 수 있는 표현식인지. */
665
+ private isMultiValueExpr(expr: Expression): boolean {
666
+ return expr.type === "CallExpression" || expr.type === "MethodCallExpression" || expr.type === "VarargExpression"
667
+ }
668
+
669
+ /**
670
+ * expr을 컴파일해서 "정확히 wantCount개"의 값을 base..base+wantCount-1에 채운다
671
+ * (모자라면 nil로 패딩). Call/MethodCall/Vararg만 1개 이상을 낼 수 있고, 그 외
672
+ * 표현식은 항상 1개만 내므로 나머지는 자동으로 nil 패딩된다.
673
+ */
674
+ private compileExprMultiInto(expr: Expression, state: FuncState, base: number, wantCount: number): void {
675
+ if (wantCount <= 0) return
676
+ if (expr.type === "CallExpression") {
677
+ const calleeReg = this.compileExpr(expr.callee, state)
678
+ const b = this.compileCallArgsAndGetB(expr.arguments, state, calleeReg + 1)
679
+ this.emit(state, Opcode.CALL, calleeReg, b, wantCount + 1)
680
+ for (let i = 0; i < wantCount; i++) {
681
+ if (calleeReg + i !== base + i) this.emit(state, Opcode.MOVE, base + i, calleeReg + i, 0)
682
+ }
683
+ state.regs.freeTemp(base + wantCount)
684
+ return
685
+ }
686
+ if (expr.type === "MethodCallExpression") {
687
+ const objReg = this.compileExpr(expr.object, state)
688
+ const k = this.konst(state, expr.method.name)
689
+ const fnSlot = state.regs.allocTemp()
690
+ this.emit(state, Opcode.SELF, fnSlot, objReg, RK(k, true))
691
+ state.regs.allocTemp()
692
+ const argB = this.compileCallArgsAndGetB(expr.arguments, state, fnSlot + 2)
693
+ const b = argB === 0 ? 0 : expr.arguments.length + 2
694
+ this.emit(state, Opcode.CALL, fnSlot, b, wantCount + 1)
695
+ for (let i = 0; i < wantCount; i++) {
696
+ if (fnSlot + i !== base + i) this.emit(state, Opcode.MOVE, base + i, fnSlot + i, 0)
697
+ }
698
+ state.regs.freeTemp(base + wantCount)
699
+ return
700
+ }
701
+ if (expr.type === "VarargExpression") {
702
+ this.emit(state, Opcode.VARARG, base, wantCount + 1, 0)
703
+ state.regs.freeTemp(base + wantCount)
704
+ return
705
+ }
706
+ // 다중값 불가능 — 1개만 채우고 나머지는 nil.
707
+ this.compileExprTo(expr, state, base)
708
+ for (let i = 1; i < wantCount; i++) this.emit(state, Opcode.LOADNIL, base + i, base + i, 0)
709
+ }
710
+
711
+ /**
712
+ * expr을 컴파일해서 "런타임이 결정하는 개수만큼" base부터 이어서 채운다(frame.multiTop
713
+ * 갱신). Call/MethodCall/Vararg가 아니면 그냥 1개로 취급(compileExprTo와 동일).
714
+ * `return f()`나 `g(a, f())`의 마지막 인자처럼 다중값을 그대로 전파할 때 씀.
715
+ */
716
+ private compileExprOpenInto(expr: Expression, state: FuncState, base: number): void {
717
+ if (expr.type === "CallExpression") {
718
+ // 결과 개수를 컴파일 타임에 모르므로(=open) CALL 이후엔 값들을 재배치할 수 없다.
719
+ // 그래서 CALL "전에" callee 자체를 base로 강제 이동시켜, CALL이 쓰는 'a'가
720
+ // 곧 base가 되도록 만든다(그래야 열린 결과가 정확히 base부터 채워짐).
721
+ const calleeReg = this.compileExpr(expr.callee, state)
722
+ if (calleeReg !== base) this.emit(state, Opcode.MOVE, base, calleeReg, 0)
723
+ state.regs.freeTemp(base + 1)
724
+ const b = this.compileCallArgsAndGetB(expr.arguments, state, base + 1)
725
+ this.emit(state, Opcode.CALL, base, b, 0)
726
+ return
727
+ }
728
+ if (expr.type === "MethodCallExpression") {
729
+ const objReg = this.compileExpr(expr.object, state)
730
+ const k = this.konst(state, expr.method.name)
731
+ const fnSlot = state.regs.allocTemp()
732
+ this.emit(state, Opcode.SELF, fnSlot, objReg, RK(k, true))
733
+ state.regs.allocTemp() // fnSlot+1 = self
734
+ if (fnSlot !== base) {
735
+ this.emit(state, Opcode.MOVE, base, fnSlot, 0)
736
+ this.emit(state, Opcode.MOVE, base + 1, fnSlot + 1, 0)
737
+ }
738
+ state.regs.freeTemp(base + 2)
739
+ const argB = this.compileCallArgsAndGetB(expr.arguments, state, base + 2)
740
+ const b = argB === 0 ? 0 : expr.arguments.length + 2
741
+ this.emit(state, Opcode.CALL, base, b, 0)
742
+ return
743
+ }
744
+ if (expr.type === "VarargExpression") {
745
+ this.emit(state, Opcode.VARARG, base, 0, 0)
746
+ return
747
+ }
748
+ this.compileExprTo(expr, state, base)
749
+ }
750
+
751
+ /**
752
+ * 호출 인자 목록을 base부터 컴파일. 마지막 인자가 다중값 가능한 표현식이면 열어서
753
+ * 끝까지 전파(`f(a, g())`가 g()의 모든 반환값을 다 넘기도록)하고 CALL의 b로 쓸 값(0)을
754
+ * 반환한다. 아니면 고정 개수(b=args.length+1)를 반환.
755
+ */
756
+ private compileCallArgsAndGetB(args: Expression[], state: FuncState, base: number): number {
757
+ if (args.length === 0) return 1
758
+ for (let i = 0; i < args.length - 1; i++) this.compileExprTo(args[i], state, base + i)
759
+ const last = args[args.length - 1]
760
+ const lastBase = base + args.length - 1
761
+ if (this.isMultiValueExpr(last)) {
762
+ this.compileExprOpenInto(last, state, lastBase)
763
+ return 0
764
+ }
765
+ this.compileExprTo(last, state, lastBase)
766
+ return args.length + 1
767
+ }
768
+
769
+ private compileExpr(expr: Expression, state: FuncState, discard = false): number {
770
+ switch (expr.type) {
771
+ case "NilLiteral": {
772
+ const r = state.regs.allocTemp()
773
+ this.emit(state, Opcode.LOADNIL, r, r, 0)
774
+ return r
775
+ }
776
+ case "BooleanLiteral": {
777
+ const r = state.regs.allocTemp()
778
+ this.emit(state, Opcode.LOADBOOL, r, expr.value ? 1 : 0, 0)
779
+ return r
780
+ }
781
+ case "NumberLiteral":
782
+ return this.loadConst(state, expr.value)
783
+ case "StringLiteral":
784
+ return this.loadConst(state, expr.value)
785
+ case "Identifier": {
786
+ const bindingId = this.bindingIdOf(expr)
787
+ if (bindingId === undefined) throw new Error("vmify: unresolved identifier")
788
+ const r = state.regs.allocTemp()
789
+ this.emitLoadBinding(state, bindingId, r)
790
+ return r
791
+ }
792
+ case "ParenthesizedExpression":
793
+ return this.compileExpr(expr.expression, state)
794
+ case "TypeAssertionExpression":
795
+ return this.compileExpr(expr.expression, state)
796
+ case "BinaryExpression": {
797
+ if (expr.operator === "and" || expr.operator === "or") {
798
+ return this.compileLogical(expr.operator, expr.left, expr.right, state)
799
+ }
800
+ let leftExpr = expr.left
801
+ let rightExpr = expr.right
802
+ let op: Opcode
803
+ let swap = false
804
+ let negate = false
805
+ switch (expr.operator) {
806
+ case "+": op = Opcode.ADD; break
807
+ case "-": op = Opcode.SUB; break
808
+ case "*": op = Opcode.MUL; break
809
+ case "/": op = Opcode.DIV; break
810
+ case "//": op = Opcode.IDIV; break
811
+ case "%": op = Opcode.MOD; break
812
+ case "^": op = Opcode.POW; break
813
+ case "..": op = Opcode.CONCAT; break
814
+ case "==": op = Opcode.EQ; break
815
+ case "~=": op = Opcode.EQ; negate = true; break
816
+ case "<": op = Opcode.LT; break
817
+ case "<=": op = Opcode.LE; break
818
+ case ">": op = Opcode.LT; swap = true; break
819
+ case ">=": op = Opcode.LE; swap = true; break
820
+ default:
821
+ throw new Error(`vmify: unsupported binary operator ${expr.operator}`)
822
+ }
823
+ if (swap) { const t = leftExpr; leftExpr = rightExpr; rightExpr = t }
824
+ const l = this.compileExpr(leftExpr, state)
825
+ const r = this.compileExpr(rightExpr, state)
826
+ const dest = state.regs.allocTemp()
827
+ this.emit(state, op, dest, l, r)
828
+ if (negate) this.emit(state, Opcode.NOT, dest, dest, 0)
829
+ state.regs.freeTemp(dest + 1)
830
+ return dest
831
+ }
832
+ case "UnaryExpression": {
833
+ const argReg = this.compileExpr(expr.argument, state)
834
+ const dest = state.regs.allocTemp()
835
+ const op = expr.operator === "-" ? Opcode.UNM : expr.operator === "not" ? Opcode.NOT : Opcode.LEN
836
+ this.emit(state, op, dest, argReg, 0)
837
+ state.regs.freeTemp(dest + 1)
838
+ return dest
839
+ }
840
+ case "TableExpression": {
841
+ const dest = state.regs.allocTemp()
842
+ this.emit(state, Opcode.NEWTABLE, dest, 0, 0)
843
+ let posIndex = 1
844
+ for (let fi = 0; fi < expr.fields.length; fi++) {
845
+ const field = expr.fields[fi]
846
+ const isLast = fi === expr.fields.length - 1
847
+ if (field.type === "TableFieldPositional") {
848
+ // 마지막 positional 필드가 Call/MethodCall/Vararg면 다중값을 전부
849
+ // 펼쳐서 posIndex, posIndex+1, ...에 채운다 (`{...}`, `{f()}`).
850
+ if (isLast && this.isMultiValueExpr(field.value)) {
851
+ const valuesBase = state.regs.allocTemp()
852
+ this.compileExprOpenInto(field.value, state, valuesBase)
853
+ this.emit(state, Opcode.SETLIST, dest, valuesBase, posIndex)
854
+ state.regs.freeTemp(valuesBase)
855
+ } else {
856
+ const v = this.compileExpr(field.value, state)
857
+ const k = this.konst(state, posIndex++)
858
+ this.emit(state, Opcode.SETTABLE, dest, RK(k, true), v)
859
+ state.regs.freeTemp(v)
860
+ }
861
+ } else if (field.type === "TableFieldNamed") {
862
+ const v = this.compileExpr(field.value, state)
863
+ const k = this.konst(state, field.name.name)
864
+ this.emit(state, Opcode.SETTABLE, dest, RK(k, true), v)
865
+ state.regs.freeTemp(v)
866
+ } else {
867
+ const k = this.compileExpr(field.key, state)
868
+ const v = this.compileExpr(field.value, state)
869
+ this.emit(state, Opcode.SETTABLE, dest, k, v)
870
+ state.regs.freeTemp(k)
871
+ }
872
+ }
873
+ return dest
874
+ }
875
+ case "MemberExpression": {
876
+ const objReg = this.compileExpr(expr.object, state)
877
+ const k = this.konst(state, expr.property.name)
878
+ const dest = state.regs.allocTemp()
879
+ this.emit(state, Opcode.GETTABLE, dest, objReg, RK(k, true))
880
+ state.regs.freeTemp(dest + 1)
881
+ return dest
882
+ }
883
+ case "IndexExpression": {
884
+ const objReg = this.compileExpr(expr.object, state)
885
+ const keyReg = this.compileExpr(expr.index, state)
886
+ const dest = state.regs.allocTemp()
887
+ this.emit(state, Opcode.GETTABLE, dest, objReg, keyReg)
888
+ state.regs.freeTemp(dest + 1)
889
+ return dest
890
+ }
891
+ case "CallExpression": {
892
+ // 주의: base는 반드시 compileExpr(callee)의 "반환값"이어야 한다 — top()을
893
+ // 미리 캐싱하면 안 됨. callee가 MemberExpression/IndexExpression처럼 내부에서
894
+ // 스크래치 레지스터를 소모하는 표현식이면 실제 함수 값은 사전 계산한 top()보다
895
+ // 한 칸 이상 위에 놓이기 때문(예: `Account.new(...)`에서 Account를 읽는 데
896
+ // 쓴 임시 레지스터 때문에 함수 값 자체는 그 다음 슬롯에 옴).
897
+ const base = this.compileExpr(expr.callee, state)
898
+ const b = this.compileCallArgsAndGetB(expr.arguments, state, base + 1)
899
+ this.emit(state, Opcode.CALL, base, b, discard ? 1 : 2)
900
+ state.regs.freeTemp(base + (discard ? 0 : 1))
901
+ return base
902
+ }
903
+ case "MethodCallExpression": {
904
+ const objReg = this.compileExpr(expr.object, state)
905
+ const k = this.konst(state, expr.method.name)
906
+ const fnSlot = state.regs.allocTemp()
907
+ this.emit(state, Opcode.SELF, fnSlot, objReg, RK(k, true))
908
+ state.regs.allocTemp()
909
+ const argB = this.compileCallArgsAndGetB(expr.arguments, state, fnSlot + 2)
910
+ // self가 항상 고정으로 하나 더 붙으므로: 열려있으면(0) 그대로 0(런타임이
911
+ // multiTop까지 다 잡아줌, self도 그 범위 안에 포함됨), 아니면 +1(self 몫).
912
+ const b = argB === 0 ? 0 : expr.arguments.length + 2
913
+ this.emit(state, Opcode.CALL, fnSlot, b, discard ? 1 : 2)
914
+ state.regs.freeTemp(fnSlot + (discard ? 0 : 1))
915
+ return fnSlot
916
+ }
917
+ case "FunctionExpression": {
918
+ const protoIdx = this.compileFunctionBody(expr.func, state)
919
+ const dest = state.regs.allocTemp()
920
+ this.emitClosure(state, dest, protoIdx)
921
+ return dest
922
+ }
923
+ case "InterpolatedStringExpression": {
924
+ let acc: number | null = null
925
+ for (const part of expr.parts) {
926
+ const partReg = part.kind === "string"
927
+ ? this.loadConst(state, part.value)
928
+ : this.compileExpr(part.expression, state)
929
+ if (acc === null) {
930
+ acc = partReg
931
+ } else {
932
+ const dest = state.regs.allocTemp()
933
+ this.emit(state, Opcode.CONCAT, dest, acc, partReg)
934
+ state.regs.freeTemp(dest + 1)
935
+ acc = dest
936
+ }
937
+ }
938
+ return acc ?? this.loadConst(state, "")
939
+ }
940
+ case "VarargExpression": {
941
+ const r = state.regs.allocTemp()
942
+ this.emit(state, Opcode.VARARG, r, 2, 0, "vararg (1 value)")
943
+ return r
944
+ }
945
+ case "IfElseExpression": {
946
+ const dest = state.regs.allocTemp()
947
+ const endPatches: number[] = []
948
+ for (const clause of expr.clauses) {
949
+ const condReg = this.compileExpr(clause.condition, state)
950
+ this.emit(state, Opcode.TEST, condReg, 0, 1) // skip next(jump-away) when truthy
951
+ const skip = this.emit(state, Opcode.JMP, 0, 0, 0)
952
+ state.regs.freeTemp(condReg)
953
+ const bodyReg = this.compileExpr(clause.body, state)
954
+ if (bodyReg !== dest) this.emit(state, Opcode.MOVE, dest, bodyReg, 0)
955
+ state.regs.freeTemp(dest + 1)
956
+ const toEnd = this.emit(state, Opcode.JMP, 0, 0, 0)
957
+ endPatches.push(toEnd)
958
+ this.patchJump(state, skip, state.proto.code.length)
959
+ }
960
+ const altReg = this.compileExpr(expr.alternate, state)
961
+ if (altReg !== dest) this.emit(state, Opcode.MOVE, dest, altReg, 0)
962
+ state.regs.freeTemp(dest + 1)
963
+ for (const p of endPatches) this.patchJump(state, p, state.proto.code.length)
964
+ return dest
965
+ }
966
+ default: {
967
+ const _exhaustive: never = expr
968
+ throw new Error(`vmify: unhandled expression ${(_exhaustive as Expression).type}`)
969
+ }
970
+ }
971
+ }
972
+
973
+ private compileLogical(operator: "and" | "or", left: Expression, right: Expression, state: FuncState): number {
974
+ const dest = this.compileExpr(left, state)
975
+ // and: 왼쪽이 falsy면 단락(오른쪽 평가 JMP를 반드시 실행) -> "JMP가 실행돼야 함"은
976
+ // skip-조건이 falsy일 때 거짓이어야 한다는 뜻 -> skip-on-truthy -> c=1.
977
+ // or: 왼쪽이 truthy면 단락 -> 반대로 skip-on-falsy -> c=0.
978
+ this.emit(state, Opcode.TEST, dest, 0, operator === "and" ? 1 : 0, `${operator} short-circuit`)
979
+ const skip = this.emit(state, Opcode.JMP, 0, 0, 0)
980
+ state.regs.freeTemp(dest)
981
+ const rightReg = this.compileExpr(right, state)
982
+ if (rightReg !== dest) this.emit(state, Opcode.MOVE, dest, rightReg, 0)
983
+ this.patchJump(state, skip, state.proto.code.length)
984
+ state.regs.freeTemp(dest + 1)
985
+ return dest
986
+ }
987
+
988
+ private emitClosure(state: FuncState, dest: number, protoIdx: number): void {
989
+ this.emit(state, Opcode.CLOSURE, dest, protoIdx, 0)
990
+ const proto = state.proto.protos[protoIdx]
991
+ for (const desc of proto.upvalDescs) {
992
+ if (desc.kind === "local") this.emit(state, Opcode.MOVE, 0, desc.index, 0, "capture local")
993
+ else this.emit(state, Opcode.GETUPVAL, 0, desc.index, 0, "capture upval")
994
+ }
995
+ }
996
+
997
+ private compileFunctionBody(fb: FunctionBody, parent: FuncState): number {
998
+ const marker = ownerFunctionOf(this.enclosing, fb)
999
+ const child = this.newFuncState(parent, marker)
1000
+ child.proto.numParams = fb.params.length
1001
+ child.proto.hasVarargs = fb.hasVarargs
1002
+ // 자연 파라미터 레지스터(0..numParams-1)를 "전부 먼저" 확정해야 한다 — 런타임
1003
+ // 프롤로그가 정확히 그 범위에 인자를 채워 넣기 때문(R[i-1]=args[i]). 캡처되는
1004
+ // 파라미터가 있다고 해서 중간에 박스 레지스터를 끼워 넣으면 그 뒤 파라미터들의
1005
+ // 자연 슬롯 번호가 밀려서 호출 규약이 깨진다 — 그래서 박스는 반드시 "두 번째 패스"에서,
1006
+ // 이미 확정된 자연 레지스터 값을 따로 옮겨 담는 방식으로 만든다.
1007
+ const naturalRegs: number[] = fb.params.map(() => child.regs.declareLocal())
1008
+ for (let i = 0; i < fb.params.length; i++) {
1009
+ const bindingId = this.bindingIdOfDecl(fb.params[i])
1010
+ if (this.captured.has(bindingId)) {
1011
+ const boxReg = child.regs.declareLocal()
1012
+ this.emitNewBox(child, boxReg)
1013
+ const k = this.konst(child, BOX_FIELD)
1014
+ this.emit(child, Opcode.SETTABLE, boxReg, RK(k, true), naturalRegs[i])
1015
+ child.localSlots.set(bindingId, { reg: boxReg, boxed: true })
1016
+ } else {
1017
+ child.localSlots.set(bindingId, { reg: naturalRegs[i], boxed: false })
1018
+ }
1019
+ }
1020
+ this.compileBlock(fb.body, child)
1021
+ this.emit(child, Opcode.RETURN, 0, 1, 0)
1022
+ child.proto.maxRegs = child.regs.maxUsed
1023
+ parent.proto.protos.push(child.proto)
1024
+ return parent.proto.protos.length - 1
1025
+ }
1026
+ }