js-confuser-vm 0.0.9 → 0.1.1

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 (64) hide show
  1. package/.gitmodules +4 -0
  2. package/CHANGELOG.md +125 -2
  3. package/README.md +128 -53
  4. package/bench.ts +146 -0
  5. package/disassemble.ts +12 -0
  6. package/dist/build-runtime.js +41 -15
  7. package/dist/compiler.js +328 -181
  8. package/dist/disassembler.js +317 -0
  9. package/dist/index.js +7 -2
  10. package/dist/runtime.js +255 -176
  11. package/dist/template.js +258 -0
  12. package/dist/transforms/bytecode/aliasedOpcodes.js +4 -1
  13. package/dist/transforms/bytecode/controlFlowFlattening.js +451 -0
  14. package/dist/transforms/bytecode/dispatcher.js +266 -0
  15. package/dist/transforms/bytecode/macroOpcodes.js +3 -3
  16. package/dist/transforms/bytecode/resolveConstants.js +100 -0
  17. package/dist/transforms/bytecode/resolveLabels.js +21 -18
  18. package/dist/transforms/bytecode/resolveRegisters.js +216 -0
  19. package/dist/transforms/bytecode/semanticOpcodes.js +162 -0
  20. package/dist/transforms/bytecode/specializedOpcodes.js +22 -12
  21. package/dist/transforms/bytecode/stringConcealing.js +110 -0
  22. package/dist/transforms/runtime/classObfuscation.js +43 -0
  23. package/dist/transforms/runtime/handlerTable.js +91 -0
  24. package/dist/transforms/runtime/semanticOpcodes.js +35 -0
  25. package/dist/transforms/runtime/specializedOpcodes.js +11 -5
  26. package/dist/types.js +42 -1
  27. package/dist/utils/ast-utils.js +14 -0
  28. package/dist/utils/op-utils.js +1 -2
  29. package/dist/utils/pass-utils.js +100 -0
  30. package/dist/utils/profile-utils.js +3 -0
  31. package/index.ts +22 -16
  32. package/jest.config.js +19 -2
  33. package/output.disassembled.js +41 -0
  34. package/package.json +2 -1
  35. package/src/build-runtime.ts +113 -78
  36. package/src/compiler.ts +2703 -2482
  37. package/src/disassembler.ts +329 -0
  38. package/src/index.ts +12 -2
  39. package/src/options.ts +8 -1
  40. package/src/runtime.ts +294 -180
  41. package/src/template.ts +265 -0
  42. package/src/transforms/bytecode/aliasedOpcodes.ts +5 -2
  43. package/src/transforms/bytecode/controlFlowFlattening.ts +566 -0
  44. package/src/transforms/bytecode/dispatcher.ts +292 -0
  45. package/src/transforms/bytecode/macroOpcodes.ts +4 -4
  46. package/src/transforms/bytecode/resolveLabels.ts +31 -27
  47. package/src/transforms/bytecode/resolveRegisters.ts +226 -0
  48. package/src/transforms/bytecode/specializedOpcodes.ts +27 -20
  49. package/src/transforms/bytecode/stringConcealing.ts +130 -0
  50. package/src/transforms/runtime/classObfuscation.ts +59 -0
  51. package/src/transforms/runtime/specializedOpcodes.ts +14 -9
  52. package/src/types.ts +106 -5
  53. package/src/utils/ast-utils.ts +19 -0
  54. package/src/utils/op-utils.ts +2 -2
  55. package/src/utils/pass-utils.ts +126 -0
  56. package/src/utils/profile-utils.ts +3 -0
  57. package/tsconfig.json +1 -1
  58. package/dist/transforms/utils/op-utils.js +0 -25
  59. package/dist/transforms/utils/random-utils.js +0 -27
  60. package/dist/utilts.js +0 -3
  61. package/src/transforms/bytecode/microOpcodes.ts +0 -291
  62. package/src/transforms/runtime/internalVariables.ts +0 -270
  63. package/src/transforms/runtime/microOpcodes.ts +0 -93
  64. /package/src/transforms/bytecode/{resolveContants.ts → resolveConstants.ts} +0 -0
@@ -0,0 +1,566 @@
1
+ // Control Flow Flattening (CFF)
2
+ //
3
+ // Splits each function into basic blocks and routes all execution through a
4
+ // while-loop + switch-style comparison chain that dispatches based on a
5
+ // `state` register. Original jumps become state transitions.
6
+ //
7
+ // ── How it works ─────────────────────────────────────────────────────────────
8
+ //
9
+ // 1. Each function's instruction stream is split into basic blocks at every
10
+ // label definition and after every terminator (JUMP, JUMP_IF_*, RETURN,
11
+ // THROW).
12
+ //
13
+ // 2. Each block is assigned a random u16 state value. A sentinel endState
14
+ // (not used by any block) marks loop termination.
15
+ //
16
+ // 3. A dispatch loop is compiled from a Template:
17
+ //
18
+ // var state = <startState>;
19
+ // while (state !== <endState>) {
20
+ // if (state === <s0>) _VM_JUMP_("<block0>");
21
+ // if (state === <s1>) _VM_JUMP_("<block1>");
22
+ // ...
23
+ // }
24
+ //
25
+ // The Template's `state` register is extracted via compileInline() so that
26
+ // block bodies can write state transitions to it.
27
+ //
28
+ // 4. Block bodies are emitted with their original instructions. Terminators
29
+ // are rewritten:
30
+ //
31
+ // JUMP target → LOAD_INT state, targetBlock.stateValue
32
+ // JUMP <loopTop>
33
+ //
34
+ // JUMP_IF_FALSE c, t → JUMP_IF_TRUE c, <skipLabel>
35
+ // LOAD_INT state, targetBlock.stateValue
36
+ // JUMP <loopTop>
37
+ // <skipLabel>:
38
+ // LOAD_INT state, fallthroughBlock.stateValue
39
+ // JUMP <loopTop>
40
+ //
41
+ // RETURN / THROW → kept in-place (exits the VM frame directly)
42
+ //
43
+ // 5. Block order is shuffled randomly so spatial locality gives no hints.
44
+ //
45
+ // ── Pipeline position ─────────────────────────────────────────────────────────
46
+ // Same slot as Dispatcher: before resolveRegisters and resolveLabels.
47
+ // Can run alongside Dispatcher (they are composable).
48
+
49
+ import type {
50
+ Bytecode,
51
+ Instruction,
52
+ RegisterOperand,
53
+ } from "../../types.ts";
54
+ import { Compiler } from "../../compiler.ts";
55
+ import { getRandomInt } from "../../utils/random-utils.ts";
56
+ import { U16_MAX } from "../../utils/op-utils.ts";
57
+ import { Template } from "../../template.ts";
58
+ import {
59
+ ref,
60
+ buildMaxIdMap,
61
+ forEachFunction,
62
+ extractLabel,
63
+ } from "../../utils/pass-utils.ts";
64
+
65
+ // ── Basic block splitting ────────────────────────────────────────────────────
66
+
67
+ interface BasicBlock {
68
+ label: string;
69
+ body: Bytecode;
70
+ terminator: Instruction | null;
71
+ stateValue: number;
72
+ // Index of the block that originally followed this one (for fallthroughs).
73
+ // -1 means "no successor" (last block, or ends with RETURN/THROW).
74
+ originalNextIndex: number;
75
+ }
76
+
77
+ function isTerminator(op: number, compiler: Compiler): boolean {
78
+ const OP = compiler.OP;
79
+ return (
80
+ op === OP.JUMP ||
81
+ op === OP.JUMP_IF_FALSE ||
82
+ op === OP.JUMP_IF_TRUE ||
83
+ op === OP.RETURN ||
84
+ op === OP.THROW
85
+ );
86
+ }
87
+
88
+ function splitBasicBlocks(
89
+ instrs: Bytecode,
90
+ compiler: Compiler,
91
+ ): BasicBlock[] {
92
+ const blocks: BasicBlock[] = [];
93
+ const usedStates = new Set<number>();
94
+
95
+ const assignState = (): number => {
96
+ let s: number;
97
+ do {
98
+ s = getRandomInt(0, U16_MAX);
99
+ } while (usedStates.has(s));
100
+ usedStates.add(s);
101
+ return s;
102
+ };
103
+
104
+ let currentLabel: string | null = null;
105
+ let currentBody: Bytecode = [];
106
+
107
+ const flushBlock = (terminator: Instruction | null) => {
108
+ if (currentBody.length === 0 && terminator === null && currentLabel === null)
109
+ return;
110
+
111
+ const label = currentLabel ?? compiler._makeLabel("cff_block");
112
+ blocks.push({
113
+ label,
114
+ body: currentBody,
115
+ terminator,
116
+ stateValue: assignState(),
117
+ originalNextIndex: -1, // filled in after all blocks are created
118
+ });
119
+ currentBody = [];
120
+ currentLabel = null;
121
+ };
122
+
123
+ for (const instr of instrs) {
124
+ const op = instr[0];
125
+
126
+ // defineLabel → start a new block boundary
127
+ if (op === null && (instr[1] as any)?.type === "defineLabel") {
128
+ flushBlock(null);
129
+ currentLabel = (instr[1] as any).label;
130
+ continue;
131
+ }
132
+
133
+ // Terminator → ends the current block
134
+ if (op !== null && isTerminator(op, compiler)) {
135
+ flushBlock(instr);
136
+ continue;
137
+ }
138
+
139
+ currentBody.push(instr);
140
+ }
141
+
142
+ // Flush trailing instructions
143
+ flushBlock(null);
144
+
145
+ // Split large blocks (> MAX_BLOCK_SIZE instructions) into smaller chunks
146
+ // so that no single block reveals too much sequential code.
147
+ const MAX_BLOCK_SIZE = 3;
148
+ const splitBlocks: BasicBlock[] = [];
149
+ for (const block of blocks) {
150
+ if (block.body.length <= MAX_BLOCK_SIZE) {
151
+ splitBlocks.push(block);
152
+ continue;
153
+ }
154
+ // Chunk the body into pieces of MAX_BLOCK_SIZE
155
+ for (let j = 0; j < block.body.length; j += MAX_BLOCK_SIZE) {
156
+ const isFirst = j === 0;
157
+ const isLast = j + MAX_BLOCK_SIZE >= block.body.length;
158
+ splitBlocks.push({
159
+ label: isFirst ? block.label : compiler._makeLabel("cff_split"),
160
+ body: block.body.slice(j, j + MAX_BLOCK_SIZE),
161
+ terminator: isLast ? block.terminator : null,
162
+ stateValue: isFirst ? block.stateValue : assignState(),
163
+ originalNextIndex: -1,
164
+ });
165
+ }
166
+ }
167
+ // Replace blocks with split result
168
+ blocks.length = 0;
169
+ blocks.push(...splitBlocks);
170
+
171
+ // Wire up originalNextIndex for fallthrough resolution
172
+ for (let i = 0; i < blocks.length - 1; i++) {
173
+ blocks[i].originalNextIndex = i + 1;
174
+ }
175
+ // Last block has no successor
176
+ if (blocks.length > 0) {
177
+ blocks[blocks.length - 1].originalNextIndex = -1;
178
+ }
179
+
180
+ return blocks;
181
+ }
182
+
183
+ // ── Cross-block register promotion ───────────────────────────────────────────
184
+ // Scans all blocks (bodies + terminators) and finds register operands that
185
+ // appear in more than one block. Those registers must not be in the "temp"
186
+ // pool because resolveRegisters' linear scan doesn't understand the CFF
187
+ // dispatch loop and would reuse their slots between blocks.
188
+ //
189
+ // Promotion is done in-place: we delete the `kind` property on the operand
190
+ // objects so they default to the "local::" pool (which never reuses slots).
191
+
192
+ function promoteMultiBlockRegisters(blocks: BasicBlock[]): void {
193
+ // (fnId, regId) → index of first block where this register was seen
194
+ const regFirstBlock = new Map<string, number>();
195
+ // Set of register keys that appear in 2+ blocks
196
+ const multiBlockRegs = new Set<string>();
197
+
198
+ for (let bi = 0; bi < blocks.length; bi++) {
199
+ const allInstrs = blocks[bi].terminator
200
+ ? [...blocks[bi].body, blocks[bi].terminator!]
201
+ : blocks[bi].body;
202
+
203
+ for (const instr of allInstrs) {
204
+ for (let j = 1; j < instr.length; j++) {
205
+ const op = instr[j] as any;
206
+ if (op && typeof op === "object" && op.type === "register") {
207
+ const key = `${op.fnId}:${op.id}`;
208
+ const first = regFirstBlock.get(key);
209
+ if (first === undefined) {
210
+ regFirstBlock.set(key, bi);
211
+ } else if (first !== bi) {
212
+ multiBlockRegs.add(key);
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ if (multiBlockRegs.size === 0) return;
220
+
221
+ // Second pass: pin all operand instances of multi-block registers so that
222
+ // resolveRegisters assigns them to the "local::" pool (no slot reuse).
223
+ for (const block of blocks) {
224
+ const allInstrs = block.terminator
225
+ ? [...block.body, block.terminator!]
226
+ : block.body;
227
+
228
+ for (const instr of allInstrs) {
229
+ for (let j = 1; j < instr.length; j++) {
230
+ const op = instr[j] as any;
231
+ if (op && typeof op === "object" && op.type === "register") {
232
+ const key = `${op.fnId}:${op.id}`;
233
+ if (multiBlockRegs.has(key)) {
234
+ op.pinned = true;
235
+ }
236
+ }
237
+ }
238
+ }
239
+ }
240
+ }
241
+
242
+ // ── Generate the dispatch loop via Template ──────────────────────────────────
243
+
244
+ function buildDispatchTemplate(
245
+ blocks: BasicBlock[],
246
+ endState: number,
247
+ startState: number,
248
+ compiler: Compiler,
249
+ fnId: number,
250
+ maxId: Map<number, number>,
251
+ ): {
252
+ bytecode: Bytecode;
253
+ rState: RegisterOperand;
254
+ loopTopLabel: string;
255
+ loopExitLabel: string;
256
+ innerBytecode: Bytecode;
257
+ } {
258
+ // Build the if-chain cases
259
+ const cases = blocks
260
+ .map(
261
+ (block) =>
262
+ `if (state === ${block.stateValue}) _VM_JUMP_("${block.label}");`,
263
+ )
264
+ .join("\n ");
265
+
266
+ const source = `
267
+ var state = ${startState};
268
+ while (state !== ${endState}) {
269
+ ${cases}
270
+ }
271
+ `;
272
+
273
+ const tmpl = new Template(source);
274
+ const result = tmpl.compileInline({}, compiler, fnId, maxId);
275
+
276
+ // Pin ALL dispatch-loop registers so resolveRegisters assigns them to the
277
+ // "local::" pool (no slot reuse). The dispatch loop is re-entered on every
278
+ // state transition (backward JUMP to while_top), but the linear-scan liveness
279
+ // in resolveRegisters doesn't track loops and would incorrectly treat dispatch
280
+ // temps as dead after one pass, allowing their slots to be reused by body
281
+ // registers that are live across blocks.
282
+ for (const instr of result.bytecode) {
283
+ for (let j = 1; j < instr.length; j++) {
284
+ const op = instr[j] as any;
285
+ if (op && typeof op === "object" && op.type === "register") {
286
+ op.pinned = true;
287
+ }
288
+ }
289
+ }
290
+
291
+ const rState = result.registers.get("state");
292
+ if (!rState) {
293
+ throw new Error("CFF: Template did not produce a 'state' register");
294
+ }
295
+
296
+ // Find the while loop labels from the compiled IR
297
+ let loopTopLabel: string | null = null;
298
+ let loopExitLabel: string | null = null;
299
+
300
+ for (const instr of result.bytecode) {
301
+ if (instr[0] === null && (instr[1] as any)?.type === "defineLabel") {
302
+ const label = (instr[1] as any).label as string;
303
+ if (label.includes("while_top") && !loopTopLabel) {
304
+ loopTopLabel = label;
305
+ }
306
+ if (label.includes("while_exit") && !loopExitLabel) {
307
+ loopExitLabel = label;
308
+ }
309
+ }
310
+ }
311
+
312
+ if (!loopTopLabel || !loopExitLabel) {
313
+ throw new Error("CFF: Could not find while loop labels in Template output");
314
+ }
315
+
316
+ return {
317
+ bytecode: result.bytecode,
318
+ rState,
319
+ loopTopLabel,
320
+ loopExitLabel,
321
+ innerBytecode: result.innerBytecode,
322
+ };
323
+ }
324
+
325
+ // ── State transition helpers ─────────────────────────────────────────────────
326
+
327
+ function emitStateTransition(
328
+ out: Bytecode,
329
+ rState: RegisterOperand,
330
+ targetState: number,
331
+ loopTopLabel: string,
332
+ compiler: Compiler,
333
+ ): void {
334
+ out.push([compiler.OP.LOAD_INT!, ref(rState), targetState]);
335
+ out.push([compiler.OP.JUMP!, { type: "label", label: loopTopLabel }]);
336
+ }
337
+
338
+ // ── Per-function transformation ──────────────────────────────────────────────
339
+
340
+ function processFunctionBlock(
341
+ instrs: Bytecode,
342
+ fnId: number,
343
+ compiler: Compiler,
344
+ maxId: Map<number, number>,
345
+ ): { instrs: Bytecode; tail: Bytecode } {
346
+ const OP = compiler.OP;
347
+
348
+ // Only transform functions that contain simple jumps
349
+ const hasRoutableJump = instrs.some((instr) => {
350
+ const op = instr[0];
351
+ return op === OP.JUMP || op === OP.JUMP_IF_FALSE || op === OP.JUMP_IF_TRUE;
352
+ });
353
+ if (!hasRoutableJump) return { instrs, tail: [] };
354
+
355
+ // ── 1. Split into basic blocks ──────────────────────────────────────────
356
+ const blocks = splitBasicBlocks(instrs, compiler);
357
+ if (blocks.length < 2) return { instrs, tail: [] };
358
+
359
+ // ── 1b. Promote cross-block registers to "local" pool ──────────────────
360
+ // resolveRegisters does a linear-scan liveness analysis that doesn't
361
+ // understand the CFF dispatch loop (backward jumps). A "temp" register
362
+ // that's live across two blocks would appear to die within its first
363
+ // block and get its slot reused, corrupting values read in later blocks.
364
+ //
365
+ // Fix: find every register that appears in more than one block and
366
+ // delete its "temp" kind so it lands in the "local::" pool (no reuse).
367
+ promoteMultiBlockRegisters(blocks);
368
+
369
+ const usedStates = new Set(blocks.map((b) => b.stateValue));
370
+
371
+ // Pick endState sentinel
372
+ let endState: number;
373
+ do {
374
+ endState = getRandomInt(0, U16_MAX);
375
+ } while (usedStates.has(endState));
376
+
377
+ const startState = blocks[0].stateValue;
378
+
379
+ // ── 2. Build dispatch loop from Template ────────────────────────────────
380
+ const dispatch = buildDispatchTemplate(
381
+ blocks,
382
+ endState,
383
+ startState,
384
+ compiler,
385
+ fnId,
386
+ maxId,
387
+ );
388
+ const { rState, loopTopLabel, loopExitLabel } = dispatch;
389
+
390
+ // ── 3. Pre-compute all state mappings BEFORE shuffle ─────────────────
391
+ // These maps capture the correct stateValues while the blocks array is
392
+ // still in its original split order. After the shuffle, indexing into
393
+ // blocks[] by original index would give the wrong block.
394
+
395
+ // label → stateValue (for jump target resolution)
396
+ const labelToState = new Map<string, number>();
397
+ for (const block of blocks) {
398
+ labelToState.set(block.label, block.stateValue);
399
+ }
400
+
401
+ // originalIndex → fallthrough stateValue
402
+ const fallthroughStateMap = new Map<number, number>();
403
+ for (let i = 0; i < blocks.length; i++) {
404
+ const next = blocks[i].originalNextIndex;
405
+ fallthroughStateMap.set(
406
+ i,
407
+ next >= 0 ? blocks[next].stateValue : endState,
408
+ );
409
+ }
410
+
411
+ // ── 4. Shuffle block order ──────────────────────────────────────────────
412
+ // Track which original index each shuffled position came from, so we can
413
+ // look up fallthroughStateMap correctly during emission.
414
+ const originalIndices = blocks.map((_, i) => i);
415
+
416
+ // Fisher-Yates shuffle
417
+ for (let i = blocks.length - 1; i > 0; i--) {
418
+ const j = getRandomInt(0, i);
419
+ [blocks[i], blocks[j]] = [blocks[j], blocks[i]];
420
+ [originalIndices[i], originalIndices[j]] = [
421
+ originalIndices[j],
422
+ originalIndices[i],
423
+ ];
424
+ }
425
+
426
+ // ── 5. Emit: dispatch loop + block bodies ───────────────────────────────
427
+ const out: Bytecode = [];
428
+
429
+ // Dispatch loop (var state = ...; while(...) { if-chain })
430
+ out.push(...dispatch.bytecode);
431
+
432
+ // Each block: defineLabel → body → state transition → JUMP loopTop
433
+ for (let i = 0; i < blocks.length; i++) {
434
+ const block = blocks[i];
435
+ const origIdx = originalIndices[i];
436
+
437
+ // Block label
438
+ out.push([null, { type: "defineLabel", label: block.label }]);
439
+
440
+ // Block body
441
+ out.push(...block.body);
442
+
443
+ // Terminator rewriting
444
+ const term = block.terminator;
445
+
446
+ if (term === null) {
447
+ // Fallthrough → transition to the original next block's state
448
+ emitStateTransition(
449
+ out,
450
+ rState,
451
+ fallthroughStateMap.get(origIdx)!,
452
+ loopTopLabel,
453
+ compiler,
454
+ );
455
+ } else if (term[0] === OP.RETURN || term[0] === OP.THROW) {
456
+ // Exits the frame — emit as-is
457
+ out.push(term);
458
+ } else if (term[0] === OP.JUMP) {
459
+ const targetLabel = extractLabel(term[1]);
460
+ if (targetLabel !== null) {
461
+ const targetState = labelToState.get(targetLabel);
462
+ if (targetState !== undefined) {
463
+ emitStateTransition(out, rState, targetState, loopTopLabel, compiler);
464
+ } else {
465
+ // Target outside this function's blocks — keep original
466
+ out.push(term);
467
+ }
468
+ } else {
469
+ out.push(term);
470
+ }
471
+ } else if (term[0] === OP.JUMP_IF_FALSE) {
472
+ // Original: if (!cond) goto target; else fallthrough
473
+ // → if (cond) goto skipLabel (inverted)
474
+ // state = targetState; goto loopTop
475
+ // skipLabel:
476
+ // state = fallthroughState; goto loopTop
477
+ const cond = term[1] as RegisterOperand;
478
+ const targetLabel = extractLabel(term[2]);
479
+
480
+ if (targetLabel !== null) {
481
+ const targetState = labelToState.get(targetLabel);
482
+ if (targetState !== undefined) {
483
+ const skipLabel = compiler._makeLabel("cff_skip");
484
+
485
+ out.push([
486
+ OP.JUMP_IF_TRUE!,
487
+ cond,
488
+ { type: "label", label: skipLabel },
489
+ ]);
490
+ emitStateTransition(
491
+ out,
492
+ rState,
493
+ targetState,
494
+ loopTopLabel,
495
+ compiler,
496
+ );
497
+ out.push([null, { type: "defineLabel", label: skipLabel }]);
498
+ emitStateTransition(
499
+ out,
500
+ rState,
501
+ fallthroughStateMap.get(origIdx)!,
502
+ loopTopLabel,
503
+ compiler,
504
+ );
505
+ } else {
506
+ out.push(term);
507
+ }
508
+ } else {
509
+ out.push(term);
510
+ }
511
+ } else if (term[0] === OP.JUMP_IF_TRUE) {
512
+ // Original: if (cond) goto target; else fallthrough
513
+ // → if (!cond) goto skipLabel (inverted)
514
+ // state = targetState; goto loopTop
515
+ // skipLabel:
516
+ // state = fallthroughState; goto loopTop
517
+ const cond = term[1] as RegisterOperand;
518
+ const targetLabel = extractLabel(term[2]);
519
+
520
+ if (targetLabel !== null) {
521
+ const targetState = labelToState.get(targetLabel);
522
+ if (targetState !== undefined) {
523
+ const skipLabel = compiler._makeLabel("cff_skip");
524
+
525
+ out.push([
526
+ OP.JUMP_IF_FALSE!,
527
+ cond,
528
+ { type: "label", label: skipLabel },
529
+ ]);
530
+ emitStateTransition(
531
+ out,
532
+ rState,
533
+ targetState,
534
+ loopTopLabel,
535
+ compiler,
536
+ );
537
+ out.push([null, { type: "defineLabel", label: skipLabel }]);
538
+ emitStateTransition(
539
+ out,
540
+ rState,
541
+ fallthroughStateMap.get(origIdx)!,
542
+ loopTopLabel,
543
+ compiler,
544
+ );
545
+ } else {
546
+ out.push(term);
547
+ }
548
+ } else {
549
+ out.push(term);
550
+ }
551
+ }
552
+ }
553
+
554
+ return { instrs: out, tail: dispatch.innerBytecode };
555
+ }
556
+
557
+ // ── Pass entry point ──────────────────────────────────────────────────────────
558
+ export function controlFlowFlattening(
559
+ bc: Bytecode,
560
+ compiler: Compiler,
561
+ ): { bytecode: Bytecode } {
562
+ const maxId = buildMaxIdMap(bc);
563
+ return forEachFunction(bc, compiler, (fnInstrs, fnId) =>
564
+ processFunctionBlock(fnInstrs, fnId, compiler, maxId),
565
+ );
566
+ }