js-confuser-vm 0.1.0 → 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 (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +75 -94
  3. package/bench.ts +146 -0
  4. package/disassemble.ts +12 -0
  5. package/dist/build-runtime.js +41 -15
  6. package/dist/compiler.js +134 -60
  7. package/dist/disassembler.js +317 -0
  8. package/dist/index.js +7 -2
  9. package/dist/runtime.js +68 -46
  10. package/dist/template.js +116 -0
  11. package/dist/transforms/bytecode/aliasedOpcodes.js +4 -1
  12. package/dist/transforms/bytecode/controlFlowFlattening.js +451 -0
  13. package/dist/transforms/bytecode/dispatcher.js +13 -109
  14. package/dist/transforms/bytecode/macroOpcodes.js +2 -2
  15. package/dist/transforms/bytecode/resolveConstants.js +100 -0
  16. package/dist/transforms/bytecode/resolveRegisters.js +4 -0
  17. package/dist/transforms/bytecode/semanticOpcodes.js +162 -0
  18. package/dist/transforms/bytecode/specializedOpcodes.js +18 -10
  19. package/dist/transforms/bytecode/stringConcealing.js +110 -0
  20. package/dist/transforms/runtime/classObfuscation.js +43 -0
  21. package/dist/transforms/runtime/handlerTable.js +91 -0
  22. package/dist/transforms/runtime/semanticOpcodes.js +35 -0
  23. package/dist/transforms/runtime/specializedOpcodes.js +11 -5
  24. package/dist/types.js +1 -1
  25. package/dist/utils/ast-utils.js +14 -0
  26. package/dist/utils/op-utils.js +0 -2
  27. package/dist/utils/pass-utils.js +100 -0
  28. package/dist/utils/profile-utils.js +3 -0
  29. package/index.ts +22 -17
  30. package/jest.config.js +14 -2
  31. package/output.disassembled.js +41 -0
  32. package/package.json +2 -1
  33. package/src/build-runtime.ts +113 -78
  34. package/src/compiler.ts +2703 -2593
  35. package/src/disassembler.ts +329 -0
  36. package/src/index.ts +12 -2
  37. package/src/options.ts +7 -1
  38. package/src/runtime.ts +84 -51
  39. package/src/template.ts +125 -1
  40. package/src/transforms/bytecode/aliasedOpcodes.ts +4 -1
  41. package/src/transforms/bytecode/controlFlowFlattening.ts +566 -0
  42. package/src/transforms/bytecode/dispatcher.ts +19 -125
  43. package/src/transforms/bytecode/macroOpcodes.ts +2 -2
  44. package/src/transforms/bytecode/resolveRegisters.ts +5 -0
  45. package/src/transforms/bytecode/specializedOpcodes.ts +22 -11
  46. package/src/transforms/bytecode/stringConcealing.ts +130 -0
  47. package/src/transforms/runtime/classObfuscation.ts +59 -0
  48. package/src/transforms/runtime/specializedOpcodes.ts +14 -9
  49. package/src/types.ts +42 -1
  50. package/src/utils/ast-utils.ts +19 -0
  51. package/src/utils/op-utils.ts +0 -2
  52. package/src/utils/pass-utils.ts +126 -0
  53. package/src/utils/profile-utils.ts +3 -0
  54. package/tsconfig.json +1 -1
  55. package/src/transforms/bytecode/microOpcodes.ts +0 -291
  56. package/src/transforms/runtime/internalVariables.ts +0 -270
  57. package/src/transforms/runtime/microOpcodes.ts +0 -93
  58. /package/src/transforms/bytecode/{resolveContants.ts → resolveConstants.ts} +0 -0
package/dist/template.js CHANGED
@@ -139,4 +139,120 @@ export class Template {
139
139
  bytecode: innerBytecode
140
140
  };
141
141
  }
142
+
143
+ // ── Inline compilation ───────────────────────────────────────────────────
144
+ /**
145
+ * Compile the template and return the **main scope** bytecode, with all
146
+ * register operands remapped to belong to `targetFnId`. This allows
147
+ * bytecode transforms to express high-level JS control flow (while-loops,
148
+ * if-chains, variable declarations) via Template and splice the result
149
+ * directly into an existing function's instruction stream.
150
+ *
151
+ * The implicit trailing RETURN added by _compileFunctionDecl is stripped —
152
+ * inline code should flow into the surrounding bytecode, not return.
153
+ *
154
+ * @param variables Substitution map for {name} placeholders.
155
+ * @param parentCompiler The Compiler whose OP table, label counter, and
156
+ * fnDescriptors are shared.
157
+ * @param targetFnId The function whose register file the template's
158
+ * registers should be remapped into.
159
+ * @param maxId Live map of max register id per fnId — updated
160
+ * in-place as new registers are allocated.
161
+ *
162
+ * @returns
163
+ * bytecode — main-scope IR (no entry defineLabel, no trailing RETURN),
164
+ * ready to splice into the target function's instruction stream.
165
+ * registers — mapping of JS variable names → remapped RegisterOperands,
166
+ * so the caller can reference template-declared variables
167
+ * (e.g. the `state` variable in CFF).
168
+ * functions — inner function descriptors (same as compile()).
169
+ * innerBytecode — inner function bytecode blocks (same as compile()).
170
+ */
171
+ compileInline(variables, parentCompiler, targetFnId, maxId) {
172
+ const code = this._interpolate(variables);
173
+ const child = new Compiler({
174
+ ...DEFAULT_OPTIONS,
175
+ randomizeOpcodes: false
176
+ });
177
+ child.OP = {
178
+ ...parentCompiler.OP
179
+ };
180
+ child.OP_NAME = {
181
+ ...parentCompiler.OP_NAME
182
+ };
183
+ child.JUMP_OPS = new Set(parentCompiler.JUMP_OPS);
184
+ child._makeLabel = parentCompiler._makeLabel.bind(parentCompiler);
185
+ const startIdx = parentCompiler.fnDescriptors.length;
186
+ child.fnDescriptors = parentCompiler.fnDescriptors;
187
+ child.compile(code);
188
+ const mainDesc = parentCompiler.fnDescriptors[startIdx];
189
+ const mainFnId = mainDesc._fnIdx;
190
+ const mainBc = mainDesc.bytecode;
191
+
192
+ // ── Remap registers from the template's main fnId → targetFnId ────────
193
+ // Build a mapping: old register id → new RegisterOperand in targetFnId.
194
+ const regRemap = new Map();
195
+ const remapReg = id => {
196
+ if (!regRemap.has(id)) {
197
+ const next = (maxId.get(targetFnId) ?? -1) + 1;
198
+ maxId.set(targetFnId, next);
199
+ regRemap.set(id, {
200
+ type: "register",
201
+ id: next,
202
+ fnId: targetFnId
203
+ });
204
+ }
205
+ return regRemap.get(id);
206
+ };
207
+ for (const instr of mainBc) {
208
+ for (let j = 1; j < instr.length; j++) {
209
+ const op = instr[j];
210
+ if (op && typeof op === "object" && op.type === "register" && op.fnId === mainFnId) {
211
+ const mapped = remapReg(op.id);
212
+ op.id = mapped.id;
213
+ op.fnId = mapped.fnId;
214
+ }
215
+ }
216
+ }
217
+
218
+ // ── Build variable name → remapped register mapping ───────────────────
219
+ const registers = new Map();
220
+ const locals = mainDesc.ctx.scope._locals;
221
+ for (const [name, reg] of locals) {
222
+ const mapped = regRemap.get(reg.id);
223
+ if (mapped) registers.set(name, mapped);
224
+ }
225
+
226
+ // ── Strip entry defineLabel and trailing implicit RETURN ───────────────
227
+ let bytecode = mainBc.filter(instr => {
228
+ const op0 = instr[1];
229
+ return !(instr[0] === null && op0?.type === "defineLabel" && op0.label === mainDesc.entryLabel);
230
+ });
231
+
232
+ // Remove trailing LOAD_CONST undefined + RETURN (implicit return added
233
+ // by _compileFunctionDecl).
234
+ const OP = parentCompiler.OP;
235
+ if (bytecode.length >= 2 && bytecode[bytecode.length - 1][0] === OP.RETURN && bytecode[bytecode.length - 2][0] === OP.LOAD_CONST) {
236
+ bytecode = bytecode.slice(0, -2);
237
+ }
238
+
239
+ // ── Inner function bytecode (same as compile()) ───────────────────────
240
+ const innerDescs = parentCompiler.fnDescriptors.slice(startIdx + 1);
241
+ const innerBytecode = [];
242
+ for (const desc of innerDescs) {
243
+ innerBytecode.push([null, {
244
+ type: "defineLabel",
245
+ label: desc.entryLabel
246
+ }]);
247
+ for (const instr of desc.bytecode) {
248
+ innerBytecode.push(instr);
249
+ }
250
+ }
251
+ return {
252
+ bytecode,
253
+ registers,
254
+ functions: innerDescs,
255
+ innerBytecode
256
+ };
257
+ }
142
258
  }
@@ -1,4 +1,4 @@
1
- import { SOURCE_NODE_SYM } from "../../compiler.js";
1
+ import { OP_ORIGINAL, SOURCE_NODE_SYM } from "../../compiler.js";
2
2
  import { nextFreeSlot } from "../../utils/op-utils.js";
3
3
  import { shuffle } from "../../utils/random-utils.js";
4
4
 
@@ -41,6 +41,9 @@ export function aliasedOpcodes(bc, compiler) {
41
41
  const arity = instr.length - 1;
42
42
  if (arity < 1) continue; // 0-operand opcodes have nothing to permute
43
43
 
44
+ const opName = compiler.OP_NAME[op];
45
+ if (!OP_ORIGINAL[opName]) continue; // only consider original ops, not already-specialized ones
46
+
44
47
  const existing = opStats.get(op);
45
48
  if (!existing) {
46
49
  opStats.set(op, {
@@ -0,0 +1,451 @@
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 { getRandomInt } from "../../utils/random-utils.js";
50
+ import { U16_MAX } from "../../utils/op-utils.js";
51
+ import { Template } from "../../template.js";
52
+ import { ref, buildMaxIdMap, forEachFunction, extractLabel } from "../../utils/pass-utils.js";
53
+
54
+ // ── Basic block splitting ────────────────────────────────────────────────────
55
+
56
+ function isTerminator(op, compiler) {
57
+ const OP = compiler.OP;
58
+ return op === OP.JUMP || op === OP.JUMP_IF_FALSE || op === OP.JUMP_IF_TRUE || op === OP.RETURN || op === OP.THROW;
59
+ }
60
+ function splitBasicBlocks(instrs, compiler) {
61
+ const blocks = [];
62
+ const usedStates = new Set();
63
+ const assignState = () => {
64
+ let s;
65
+ do {
66
+ s = getRandomInt(0, U16_MAX);
67
+ } while (usedStates.has(s));
68
+ usedStates.add(s);
69
+ return s;
70
+ };
71
+ let currentLabel = null;
72
+ let currentBody = [];
73
+ const flushBlock = terminator => {
74
+ if (currentBody.length === 0 && terminator === null && currentLabel === null) return;
75
+ const label = currentLabel ?? compiler._makeLabel("cff_block");
76
+ blocks.push({
77
+ label,
78
+ body: currentBody,
79
+ terminator,
80
+ stateValue: assignState(),
81
+ originalNextIndex: -1 // filled in after all blocks are created
82
+ });
83
+ currentBody = [];
84
+ currentLabel = null;
85
+ };
86
+ for (const instr of instrs) {
87
+ const op = instr[0];
88
+
89
+ // defineLabel → start a new block boundary
90
+ if (op === null && instr[1]?.type === "defineLabel") {
91
+ flushBlock(null);
92
+ currentLabel = instr[1].label;
93
+ continue;
94
+ }
95
+
96
+ // Terminator → ends the current block
97
+ if (op !== null && isTerminator(op, compiler)) {
98
+ flushBlock(instr);
99
+ continue;
100
+ }
101
+ currentBody.push(instr);
102
+ }
103
+
104
+ // Flush trailing instructions
105
+ flushBlock(null);
106
+
107
+ // Split large blocks (> MAX_BLOCK_SIZE instructions) into smaller chunks
108
+ // so that no single block reveals too much sequential code.
109
+ const MAX_BLOCK_SIZE = 3;
110
+ const splitBlocks = [];
111
+ for (const block of blocks) {
112
+ if (block.body.length <= MAX_BLOCK_SIZE) {
113
+ splitBlocks.push(block);
114
+ continue;
115
+ }
116
+ // Chunk the body into pieces of MAX_BLOCK_SIZE
117
+ for (let j = 0; j < block.body.length; j += MAX_BLOCK_SIZE) {
118
+ const isFirst = j === 0;
119
+ const isLast = j + MAX_BLOCK_SIZE >= block.body.length;
120
+ splitBlocks.push({
121
+ label: isFirst ? block.label : compiler._makeLabel("cff_split"),
122
+ body: block.body.slice(j, j + MAX_BLOCK_SIZE),
123
+ terminator: isLast ? block.terminator : null,
124
+ stateValue: isFirst ? block.stateValue : assignState(),
125
+ originalNextIndex: -1
126
+ });
127
+ }
128
+ }
129
+ // Replace blocks with split result
130
+ blocks.length = 0;
131
+ blocks.push(...splitBlocks);
132
+
133
+ // Wire up originalNextIndex for fallthrough resolution
134
+ for (let i = 0; i < blocks.length - 1; i++) {
135
+ blocks[i].originalNextIndex = i + 1;
136
+ }
137
+ // Last block has no successor
138
+ if (blocks.length > 0) {
139
+ blocks[blocks.length - 1].originalNextIndex = -1;
140
+ }
141
+ return blocks;
142
+ }
143
+
144
+ // ── Cross-block register promotion ───────────────────────────────────────────
145
+ // Scans all blocks (bodies + terminators) and finds register operands that
146
+ // appear in more than one block. Those registers must not be in the "temp"
147
+ // pool because resolveRegisters' linear scan doesn't understand the CFF
148
+ // dispatch loop and would reuse their slots between blocks.
149
+ //
150
+ // Promotion is done in-place: we delete the `kind` property on the operand
151
+ // objects so they default to the "local::" pool (which never reuses slots).
152
+
153
+ function promoteMultiBlockRegisters(blocks) {
154
+ // (fnId, regId) → index of first block where this register was seen
155
+ const regFirstBlock = new Map();
156
+ // Set of register keys that appear in 2+ blocks
157
+ const multiBlockRegs = new Set();
158
+ for (let bi = 0; bi < blocks.length; bi++) {
159
+ const allInstrs = blocks[bi].terminator ? [...blocks[bi].body, blocks[bi].terminator] : blocks[bi].body;
160
+ for (const instr of allInstrs) {
161
+ for (let j = 1; j < instr.length; j++) {
162
+ const op = instr[j];
163
+ if (op && typeof op === "object" && op.type === "register") {
164
+ const key = `${op.fnId}:${op.id}`;
165
+ const first = regFirstBlock.get(key);
166
+ if (first === undefined) {
167
+ regFirstBlock.set(key, bi);
168
+ } else if (first !== bi) {
169
+ multiBlockRegs.add(key);
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }
175
+ if (multiBlockRegs.size === 0) return;
176
+
177
+ // Second pass: pin all operand instances of multi-block registers so that
178
+ // resolveRegisters assigns them to the "local::" pool (no slot reuse).
179
+ for (const block of blocks) {
180
+ const allInstrs = block.terminator ? [...block.body, block.terminator] : block.body;
181
+ for (const instr of allInstrs) {
182
+ for (let j = 1; j < instr.length; j++) {
183
+ const op = instr[j];
184
+ if (op && typeof op === "object" && op.type === "register") {
185
+ const key = `${op.fnId}:${op.id}`;
186
+ if (multiBlockRegs.has(key)) {
187
+ op.pinned = true;
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ // ── Generate the dispatch loop via Template ──────────────────────────────────
196
+
197
+ function buildDispatchTemplate(blocks, endState, startState, compiler, fnId, maxId) {
198
+ // Build the if-chain cases
199
+ const cases = blocks.map(block => `if (state === ${block.stateValue}) _VM_JUMP_("${block.label}");`).join("\n ");
200
+ const source = `
201
+ var state = ${startState};
202
+ while (state !== ${endState}) {
203
+ ${cases}
204
+ }
205
+ `;
206
+ const tmpl = new Template(source);
207
+ const result = tmpl.compileInline({}, compiler, fnId, maxId);
208
+
209
+ // Pin ALL dispatch-loop registers so resolveRegisters assigns them to the
210
+ // "local::" pool (no slot reuse). The dispatch loop is re-entered on every
211
+ // state transition (backward JUMP to while_top), but the linear-scan liveness
212
+ // in resolveRegisters doesn't track loops and would incorrectly treat dispatch
213
+ // temps as dead after one pass, allowing their slots to be reused by body
214
+ // registers that are live across blocks.
215
+ for (const instr of result.bytecode) {
216
+ for (let j = 1; j < instr.length; j++) {
217
+ const op = instr[j];
218
+ if (op && typeof op === "object" && op.type === "register") {
219
+ op.pinned = true;
220
+ }
221
+ }
222
+ }
223
+ const rState = result.registers.get("state");
224
+ if (!rState) {
225
+ throw new Error("CFF: Template did not produce a 'state' register");
226
+ }
227
+
228
+ // Find the while loop labels from the compiled IR
229
+ let loopTopLabel = null;
230
+ let loopExitLabel = null;
231
+ for (const instr of result.bytecode) {
232
+ if (instr[0] === null && instr[1]?.type === "defineLabel") {
233
+ const label = instr[1].label;
234
+ if (label.includes("while_top") && !loopTopLabel) {
235
+ loopTopLabel = label;
236
+ }
237
+ if (label.includes("while_exit") && !loopExitLabel) {
238
+ loopExitLabel = label;
239
+ }
240
+ }
241
+ }
242
+ if (!loopTopLabel || !loopExitLabel) {
243
+ throw new Error("CFF: Could not find while loop labels in Template output");
244
+ }
245
+ return {
246
+ bytecode: result.bytecode,
247
+ rState,
248
+ loopTopLabel,
249
+ loopExitLabel,
250
+ innerBytecode: result.innerBytecode
251
+ };
252
+ }
253
+
254
+ // ── State transition helpers ─────────────────────────────────────────────────
255
+
256
+ function emitStateTransition(out, rState, targetState, loopTopLabel, compiler) {
257
+ out.push([compiler.OP.LOAD_INT, ref(rState), targetState]);
258
+ out.push([compiler.OP.JUMP, {
259
+ type: "label",
260
+ label: loopTopLabel
261
+ }]);
262
+ }
263
+
264
+ // ── Per-function transformation ──────────────────────────────────────────────
265
+
266
+ function processFunctionBlock(instrs, fnId, compiler, maxId) {
267
+ const OP = compiler.OP;
268
+
269
+ // Only transform functions that contain simple jumps
270
+ const hasRoutableJump = instrs.some(instr => {
271
+ const op = instr[0];
272
+ return op === OP.JUMP || op === OP.JUMP_IF_FALSE || op === OP.JUMP_IF_TRUE;
273
+ });
274
+ if (!hasRoutableJump) return {
275
+ instrs,
276
+ tail: []
277
+ };
278
+
279
+ // ── 1. Split into basic blocks ──────────────────────────────────────────
280
+ const blocks = splitBasicBlocks(instrs, compiler);
281
+ if (blocks.length < 2) return {
282
+ instrs,
283
+ tail: []
284
+ };
285
+
286
+ // ── 1b. Promote cross-block registers to "local" pool ──────────────────
287
+ // resolveRegisters does a linear-scan liveness analysis that doesn't
288
+ // understand the CFF dispatch loop (backward jumps). A "temp" register
289
+ // that's live across two blocks would appear to die within its first
290
+ // block and get its slot reused, corrupting values read in later blocks.
291
+ //
292
+ // Fix: find every register that appears in more than one block and
293
+ // delete its "temp" kind so it lands in the "local::" pool (no reuse).
294
+ promoteMultiBlockRegisters(blocks);
295
+ const usedStates = new Set(blocks.map(b => b.stateValue));
296
+
297
+ // Pick endState sentinel
298
+ let endState;
299
+ do {
300
+ endState = getRandomInt(0, U16_MAX);
301
+ } while (usedStates.has(endState));
302
+ const startState = blocks[0].stateValue;
303
+
304
+ // ── 2. Build dispatch loop from Template ────────────────────────────────
305
+ const dispatch = buildDispatchTemplate(blocks, endState, startState, compiler, fnId, maxId);
306
+ const {
307
+ rState,
308
+ loopTopLabel,
309
+ loopExitLabel
310
+ } = dispatch;
311
+
312
+ // ── 3. Pre-compute all state mappings BEFORE shuffle ─────────────────
313
+ // These maps capture the correct stateValues while the blocks array is
314
+ // still in its original split order. After the shuffle, indexing into
315
+ // blocks[] by original index would give the wrong block.
316
+
317
+ // label → stateValue (for jump target resolution)
318
+ const labelToState = new Map();
319
+ for (const block of blocks) {
320
+ labelToState.set(block.label, block.stateValue);
321
+ }
322
+
323
+ // originalIndex → fallthrough stateValue
324
+ const fallthroughStateMap = new Map();
325
+ for (let i = 0; i < blocks.length; i++) {
326
+ const next = blocks[i].originalNextIndex;
327
+ fallthroughStateMap.set(i, next >= 0 ? blocks[next].stateValue : endState);
328
+ }
329
+
330
+ // ── 4. Shuffle block order ──────────────────────────────────────────────
331
+ // Track which original index each shuffled position came from, so we can
332
+ // look up fallthroughStateMap correctly during emission.
333
+ const originalIndices = blocks.map((_, i) => i);
334
+
335
+ // Fisher-Yates shuffle
336
+ for (let i = blocks.length - 1; i > 0; i--) {
337
+ const j = getRandomInt(0, i);
338
+ [blocks[i], blocks[j]] = [blocks[j], blocks[i]];
339
+ [originalIndices[i], originalIndices[j]] = [originalIndices[j], originalIndices[i]];
340
+ }
341
+
342
+ // ── 5. Emit: dispatch loop + block bodies ───────────────────────────────
343
+ const out = [];
344
+
345
+ // Dispatch loop (var state = ...; while(...) { if-chain })
346
+ out.push(...dispatch.bytecode);
347
+
348
+ // Each block: defineLabel → body → state transition → JUMP loopTop
349
+ for (let i = 0; i < blocks.length; i++) {
350
+ const block = blocks[i];
351
+ const origIdx = originalIndices[i];
352
+
353
+ // Block label
354
+ out.push([null, {
355
+ type: "defineLabel",
356
+ label: block.label
357
+ }]);
358
+
359
+ // Block body
360
+ out.push(...block.body);
361
+
362
+ // Terminator rewriting
363
+ const term = block.terminator;
364
+ if (term === null) {
365
+ // Fallthrough → transition to the original next block's state
366
+ emitStateTransition(out, rState, fallthroughStateMap.get(origIdx), loopTopLabel, compiler);
367
+ } else if (term[0] === OP.RETURN || term[0] === OP.THROW) {
368
+ // Exits the frame — emit as-is
369
+ out.push(term);
370
+ } else if (term[0] === OP.JUMP) {
371
+ const targetLabel = extractLabel(term[1]);
372
+ if (targetLabel !== null) {
373
+ const targetState = labelToState.get(targetLabel);
374
+ if (targetState !== undefined) {
375
+ emitStateTransition(out, rState, targetState, loopTopLabel, compiler);
376
+ } else {
377
+ // Target outside this function's blocks — keep original
378
+ out.push(term);
379
+ }
380
+ } else {
381
+ out.push(term);
382
+ }
383
+ } else if (term[0] === OP.JUMP_IF_FALSE) {
384
+ // Original: if (!cond) goto target; else fallthrough
385
+ // → if (cond) goto skipLabel (inverted)
386
+ // state = targetState; goto loopTop
387
+ // skipLabel:
388
+ // state = fallthroughState; goto loopTop
389
+ const cond = term[1];
390
+ const targetLabel = extractLabel(term[2]);
391
+ if (targetLabel !== null) {
392
+ const targetState = labelToState.get(targetLabel);
393
+ if (targetState !== undefined) {
394
+ const skipLabel = compiler._makeLabel("cff_skip");
395
+ out.push([OP.JUMP_IF_TRUE, cond, {
396
+ type: "label",
397
+ label: skipLabel
398
+ }]);
399
+ emitStateTransition(out, rState, targetState, loopTopLabel, compiler);
400
+ out.push([null, {
401
+ type: "defineLabel",
402
+ label: skipLabel
403
+ }]);
404
+ emitStateTransition(out, rState, fallthroughStateMap.get(origIdx), loopTopLabel, compiler);
405
+ } else {
406
+ out.push(term);
407
+ }
408
+ } else {
409
+ out.push(term);
410
+ }
411
+ } else if (term[0] === OP.JUMP_IF_TRUE) {
412
+ // Original: if (cond) goto target; else fallthrough
413
+ // → if (!cond) goto skipLabel (inverted)
414
+ // state = targetState; goto loopTop
415
+ // skipLabel:
416
+ // state = fallthroughState; goto loopTop
417
+ const cond = term[1];
418
+ const targetLabel = extractLabel(term[2]);
419
+ if (targetLabel !== null) {
420
+ const targetState = labelToState.get(targetLabel);
421
+ if (targetState !== undefined) {
422
+ const skipLabel = compiler._makeLabel("cff_skip");
423
+ out.push([OP.JUMP_IF_FALSE, cond, {
424
+ type: "label",
425
+ label: skipLabel
426
+ }]);
427
+ emitStateTransition(out, rState, targetState, loopTopLabel, compiler);
428
+ out.push([null, {
429
+ type: "defineLabel",
430
+ label: skipLabel
431
+ }]);
432
+ emitStateTransition(out, rState, fallthroughStateMap.get(origIdx), loopTopLabel, compiler);
433
+ } else {
434
+ out.push(term);
435
+ }
436
+ } else {
437
+ out.push(term);
438
+ }
439
+ }
440
+ }
441
+ return {
442
+ instrs: out,
443
+ tail: dispatch.innerBytecode
444
+ };
445
+ }
446
+
447
+ // ── Pass entry point ──────────────────────────────────────────────────────────
448
+ export function controlFlowFlattening(bc, compiler) {
449
+ const maxId = buildMaxIdMap(bc);
450
+ return forEachFunction(bc, compiler, (fnInstrs, fnId) => processFunctionBlock(fnInstrs, fnId, compiler, maxId));
451
+ }