js-confuser-vm 0.0.5 → 0.0.6

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 (34) hide show
  1. package/CHANGELOG.md +57 -2
  2. package/README.MD +186 -107
  3. package/dist/build-runtime.js +7 -1
  4. package/dist/compiler.js +801 -785
  5. package/dist/runtime.js +409 -332
  6. package/dist/transforms/bytecode/aliasedOpcodes.js +140 -0
  7. package/dist/transforms/bytecode/concealConstants.js +31 -0
  8. package/dist/transforms/bytecode/macroOpcodes.js +22 -10
  9. package/dist/transforms/bytecode/resolveContants.js +73 -10
  10. package/dist/transforms/bytecode/selfModifying.js +3 -2
  11. package/dist/transforms/bytecode/specializedOpcodes.js +38 -28
  12. package/dist/transforms/runtime/aliasedOpcodes.js +134 -0
  13. package/dist/transforms/runtime/shuffleOpcodes.js +1 -1
  14. package/dist/transforms/runtime/specializedOpcodes.js +21 -16
  15. package/dist/utils/op-utils.js +29 -0
  16. package/dist/utils/random-utils.js +27 -0
  17. package/index.ts +10 -8
  18. package/jest.config.js +10 -0
  19. package/package.json +1 -1
  20. package/src/build-runtime.ts +7 -1
  21. package/src/compiler.ts +2395 -2069
  22. package/src/options.ts +2 -0
  23. package/src/runtime.ts +838 -771
  24. package/src/transforms/bytecode/aliasedOpcodes.ts +158 -0
  25. package/src/transforms/bytecode/concealConstants.ts +52 -0
  26. package/src/transforms/bytecode/macroOpcodes.ts +32 -15
  27. package/src/transforms/bytecode/resolveContants.ts +87 -16
  28. package/src/transforms/bytecode/selfModifying.ts +3 -3
  29. package/src/transforms/bytecode/specializedOpcodes.ts +58 -29
  30. package/src/transforms/runtime/aliasedOpcodes.ts +191 -0
  31. package/src/transforms/runtime/shuffleOpcodes.ts +1 -1
  32. package/src/transforms/runtime/specializedOpcodes.ts +39 -24
  33. package/src/{transforms/utils → utils}/op-utils.ts +7 -0
  34. /package/src/{transforms/utils → utils}/random-utils.ts +0 -0
@@ -0,0 +1,140 @@
1
+ import { SOURCE_NODE_SYM } from "../../compiler.js";
2
+ import { nextFreeSlot, U16_MAX } from "../../utils/op-utils.js";
3
+ import { shuffle } from "../../utils/random-utils.js";
4
+
5
+ // Opcodes that must not be aliased.
6
+ // Variable-length operand opcodes cannot be statically aliased since the
7
+ // number of this._operand() calls varies at runtime.
8
+ // Infrastructure opcodes (PATCH, TRY_SETUP, TRY_END, DEBUGGER) are excluded
9
+ // because aliasing them would interfere with self-modifying bytecode and
10
+ // exception-handling machinery.
11
+ const DISALLOWED_OP_NAMES = new Set(["MAKE_CLOSURE", "BUILD_ARRAY", "BUILD_OBJECT", "CALL", "CALL_METHOD", "NEW", "PATCH", "TRY_SETUP", "TRY_END", "DEBUGGER"]);
12
+
13
+ // Creates aliased opcodes: duplicate handlers for commonly-used opcodes,
14
+ // optionally with a permuted operand read order in the bytecode stream.
15
+ //
16
+ // For each aliased op, we record an `order` permutation of length `arity`.
17
+ // order[i] = j means: bytecode slot i holds what was originally operand j.
18
+ //
19
+ // Example: LOAD_GLOBAL [dst, nameIdx] with order=[1,0]:
20
+ // Bytecode stores: [ALIAS_OP, nameIdx, dst]
21
+ // Handler reads: _unsortedOperands = [nameIdx, dst]
22
+ // _operands = [_unsortedOperands[1], _unsortedOperands[0]]
23
+ // = [dst, nameIdx] ← original order restored
24
+ //
25
+ // Runs LAST among bytecode transforms (after selfModifying), before resolveLabels.
26
+ export function aliasedOpcodes(bc, compiler) {
27
+ // Build a map of base opcode value → name, excluding disallowed ops
28
+ const baseOpValueToName = new Map();
29
+ for (const [name, val] of Object.entries(compiler.OP)) {
30
+ if (DISALLOWED_OP_NAMES.has(name)) continue;
31
+ baseOpValueToName.set(val, name);
32
+ }
33
+
34
+ // Collect all currently used opcode slots (base + any dynamically assigned)
35
+ const usedOpcodes = new Set(Object.keys(compiler.OP_NAME).map(k => parseInt(k, 10)).filter(v => !isNaN(v)));
36
+ if (usedOpcodes.size > U16_MAX) return {
37
+ bytecode: bc
38
+ };
39
+
40
+ // ── Step 1: count frequency and determine arity for each eligible base opcode ─
41
+ // We scan the actual post-transform bytecode so frequency reflects what's
42
+ // really left (specialized/macro ops already consumed their share).
43
+ const opStats = new Map();
44
+ for (const instr of bc) {
45
+ const op = instr[0];
46
+ if (op === null || !baseOpValueToName.has(op)) continue;
47
+ const arity = instr.length - 1;
48
+ if (arity < 1) continue; // 0-operand opcodes have nothing to permute
49
+
50
+ const existing = opStats.get(op);
51
+ if (!existing) {
52
+ opStats.set(op, {
53
+ freq: 1,
54
+ arity
55
+ });
56
+ } else {
57
+ if (existing.arity !== arity) {
58
+ // Inconsistent arity → variable-length; skip
59
+ existing.arity = null;
60
+ }
61
+ existing.freq++;
62
+ }
63
+ }
64
+
65
+ // ── Step 2: sort by frequency descending, keep only consistent-arity ops ────
66
+ const candidates = Array.from(opStats.entries()).filter(([, s]) => s.arity !== null).sort(([, a], [, b]) => b.freq - a.freq);
67
+ if (candidates.length === 0) return {
68
+ bytecode: bc
69
+ };
70
+
71
+ // ── Step 3: assign free slots, build order permutations ─────────────────────
72
+ // aliasMap: originalOp → aliasOp (only the winning alias per original op)
73
+ const aliasMap = new Map();
74
+ const aliasedOps = {};
75
+ for (const [originalOp, stats] of candidates) {
76
+ const aliasOp = nextFreeSlot(usedOpcodes);
77
+ if (aliasOp === -1) break;
78
+ const arity = stats.arity;
79
+
80
+ // Build a permutation of [0 .. arity-1].
81
+ // For arity >= 2: shuffle until we get a non-identity permutation so the
82
+ // operand order is actually different (makes the alias more confusing).
83
+ // For arity == 1: only one permutation exists ([0]); still useful as a clone.
84
+ let order;
85
+ if (arity >= 2) {
86
+ const identity = Array.from({
87
+ length: arity
88
+ }, (_, i) => i);
89
+ let attempts = 0;
90
+ do {
91
+ order = shuffle([...identity]);
92
+ attempts++;
93
+ } while (attempts < 20 && order.every((v, i) => v === i));
94
+ } else {
95
+ order = [0];
96
+ }
97
+ aliasMap.set(originalOp, aliasOp);
98
+ aliasedOps[aliasOp] = {
99
+ originalOp,
100
+ order
101
+ };
102
+ const originalName = compiler.OP_NAME[originalOp] ?? `OP_${originalOp}`;
103
+ compiler.OP_NAME[aliasOp] = `ALIAS_${originalName}_${order.join("_")}`;
104
+ }
105
+ compiler.ALIASED_OPS = aliasedOps;
106
+ if (aliasMap.size === 0) return {
107
+ bytecode: bc
108
+ };
109
+
110
+ // ── Step 4: rewrite bytecode ─────────────────────────────────────────────────
111
+ const result = [];
112
+ for (const instr of bc) {
113
+ const op = instr[0];
114
+ if (op === null || !aliasMap.has(op)) {
115
+ result.push(instr);
116
+ continue;
117
+ }
118
+ const aliasOp = aliasMap.get(op);
119
+ const {
120
+ order
121
+ } = aliasedOps[aliasOp];
122
+ const originalOperands = instr.slice(1);
123
+
124
+ // Guard: if arity changed (shouldn't happen after the consistency check),
125
+ // fall back to the original instruction.
126
+ if (originalOperands.length !== order.length) {
127
+ result.push(instr);
128
+ continue;
129
+ }
130
+
131
+ // Rearrange operands: new slot i receives original operand order[i].
132
+ const newOperands = order.map(i => originalOperands[i]);
133
+ const newInstr = [aliasOp, ...newOperands];
134
+ newInstr[SOURCE_NODE_SYM] = instr[SOURCE_NODE_SYM];
135
+ result.push(newInstr);
136
+ }
137
+ return {
138
+ bytecode: result
139
+ };
140
+ }
@@ -0,0 +1,31 @@
1
+ export function concealConstants(bytecode, compiler) {
2
+ const newBytecode = [];
3
+ for (const instr of bytecode) {
4
+ const [op, ...operands] = instr;
5
+ const hasContant = operands.some(o => o !== undefined && o !== null && typeof o === "object" && o.type === "constant");
6
+ if (!hasContant) {
7
+ newBytecode.push(instr);
8
+ continue;
9
+ }
10
+ const newOperands = [];
11
+ for (const operand of operands) {
12
+ if (operand?.type === "constant") {
13
+ const tsOperand = operand;
14
+ newOperands.push(operand);
15
+ newOperands.push({
16
+ type: "constant",
17
+ value: tsOperand.value,
18
+ key: true
19
+ });
20
+ } else {
21
+ newOperands.push(operand);
22
+ }
23
+ }
24
+ instr.length = 0;
25
+ instr.push(op, ...newOperands);
26
+ newBytecode.push(instr);
27
+ }
28
+ return {
29
+ bytecode: newBytecode
30
+ };
31
+ }
@@ -1,14 +1,16 @@
1
1
  import { SOURCE_NODE_SYM } from "../../compiler.js";
2
- import { nextFreeSlot, U16_MAX } from "../utils/op-utils.js";
2
+ import { nextFreeSlot, U16_MAX } from "../../utils/op-utils.js";
3
3
 
4
- // Opcodes that must not appear inside a macro window.
4
+ // Opcodes that must not appear in a non-terminal position inside a macro window.
5
5
  // Jump ops: modifying frame._pc mid-execution causes the macro handler to
6
6
  // run subsequent sub-bodies even after the jump already fired.
7
7
  // Frame-changing ops (CALL, CALL_METHOD, NEW, RETURN, THROW): push/pop call
8
8
  // frames mid-macro, leaving the `frame` variable stale for later sub-bodies.
9
+ // When one of these is the LAST instruction in the macro sequence there are no
10
+ // following sub-bodies, so editing _pc or the call frame is safe.
9
11
  // Variable-operand ops (MAKE_CLOSURE): the number of _operand() calls depends
10
12
  // on uvCount at runtime, so a static handler cannot be generated.
11
- // Infrastructure ops (DATA, PATCH, TRY_SETUP, TRY_END, DEBUGGER):
13
+ // Infrastructure ops (PATCH, TRY_SETUP, TRY_END, DEBUGGER):
12
14
  // either illegal here or nonsensical to fold.
13
15
 
14
16
  // Scan bytecode for repeating instruction sequences and fold them into
@@ -31,16 +33,23 @@ export function macroOpcodes(bc, compiler) {
31
33
  const opVal = compiler.OP[name];
32
34
  originalOpToName.set(opVal, name);
33
35
  }
34
- function isEligible(op, compiler) {
36
+ function isEligible(op, compiler, isLast = false) {
35
37
  if (op === null) return false;
36
38
  const {
37
39
  OP,
38
40
  JUMP_OPS
39
41
  } = compiler;
40
- if (JUMP_OPS.has(op)) return false;
41
- const excluded = new Set([OP.RETURN, OP.PATCH, OP.TRY_SETUP, OP.TRY_END, OP.DEBUGGER, OP.CALL, OP.CALL_METHOD, OP.NEW, OP.THROW, OP.MAKE_CLOSURE // variable-length operands — cannot generate a static handler
42
+ // Infrastructure and variable-length ops are never eligible.
43
+ const alwaysExcluded = new Set([OP.PATCH, OP.TRY_SETUP, OP.TRY_END, OP.DEBUGGER, OP.MAKE_CLOSURE // variable-length operands — cannot generate a static handler
42
44
  ]);
43
- return !excluded.has(op) && originalOpToName.has(op); // Only original Ops are eligible (specialized disallowed)
45
+ if (alwaysExcluded.has(op)) return false;
46
+ // Jump and frame-changing ops are only eligible as the terminal instruction.
47
+ if (!isLast) {
48
+ if (JUMP_OPS.has(op)) return false;
49
+ const nonTerminalExcluded = new Set([OP.RETURN, OP.CALL, OP.CALL_METHOD, OP.NEW, OP.THROW]);
50
+ if (nonTerminalExcluded.has(op)) return false;
51
+ }
52
+ return originalOpToName.has(op); // Only original Ops are eligible (specialized disallowed)
44
53
  }
45
54
 
46
55
  // Collect every opcode value already in use so we can find free slots.
@@ -58,13 +67,15 @@ export function macroOpcodes(bc, compiler) {
58
67
  let valid = true;
59
68
  for (let j = 0; j < len; j++) {
60
69
  const op = bc[i + j][0];
61
- if (!isEligible(op, compiler)) {
70
+ const isLast = j === len - 1;
71
+ if (!isEligible(op, compiler, isLast)) {
62
72
  valid = false;
63
73
  break;
64
74
  }
65
75
  ops.push(op);
66
76
  }
67
- // If position (i+j) is ineligible, longer windows from i are also invalid.
77
+ // If position (i+j) is ineligible even as a terminal, longer windows from
78
+ // i are also invalid (it would be non-terminal there too).
68
79
  if (!valid) break;
69
80
  const key = ops.join(",");
70
81
  const entry = freqMap.get(key);
@@ -117,7 +128,8 @@ export function macroOpcodes(bc, compiler) {
117
128
  for (let j = 0; j < len; j++) {
118
129
  const instr = bc[i + j];
119
130
  const op = instr[0];
120
- if (!isEligible(op, compiler)) {
131
+ const isLast = j === len - 1;
132
+ if (!isEligible(op, compiler, isLast)) {
121
133
  valid = false;
122
134
  break;
123
135
  }
@@ -1,34 +1,97 @@
1
1
  import { SOURCE_NODE_SYM } from "../../compiler.js";
2
+ import { getRandomInt } from "../../utils/random-utils.js";
3
+ import { U16_MAX } from "../../utils/op-utils.js";
2
4
 
3
- // Resolve all {type:"constant", value} operands to integer indices into the
4
- // constants pool. Returns both the resolved bytecode and the constants array
5
- // so the Serializer can use it for comment generation and output.
6
- // Constant refs may appear at any operand position (index 1, 2, 3, …).
7
- export function resolveConstants(bc) {
5
+ // Encrypt a string with a position-dependent XOR key (u16) then base64-encode.
6
+ //
7
+ // Each char code is XOR'd with ((key + i) & 0xFFFF), producing a u16 value.
8
+ // The u16 values are packed as little-endian byte pairs (matching decodeBytecode),
9
+ // then base64-encoded so the stored constant is always safe ASCII — no raw Unicode
10
+ // surrogates, control chars, or quote chars that would break JS string literals.
11
+ function concealString(s, key) {
12
+ const bytes = new Uint8Array(s.length * 2);
13
+ for (let i = 0; i < s.length; i++) {
14
+ const code = s.charCodeAt(i) ^ key + i & 0xffff;
15
+ bytes[i * 2] = code & 0xff;
16
+ bytes[i * 2 + 1] = code >> 8 & 0xff;
17
+ }
18
+ return Buffer.from(bytes).toString("base64");
19
+ }
20
+
21
+ // Resolve all {type:"constant", value} operands to a PAIR of integer operands:
22
+ // [constPoolIndex, concealKey]
23
+ //
24
+ // constPoolIndex — index into the constants array (as before).
25
+ // concealKey — XOR key used to conceal this constant.
26
+ // 0 means no concealment (concealConstants is off, or the
27
+ // value type is not concealable: null, undefined, bool, float…).
28
+ //
29
+ // The constants array stores the CONCEALED value when key != 0.
30
+ // The runtime's _readConstant(idx, key) reverses the concealment on the fly.
31
+ //
32
+ // Both slots are u16; all existing operand serialization handles them identically.
33
+ export function resolveConstants(bc, compiler) {
8
34
  const constants = [];
9
- const constantsMap = new Map();
35
+ const constantsMap = new Map(); // original value → pool index
36
+ const keyMap = new Map(); // pool index → conceal key
37
+
10
38
  function intern(operand) {
11
39
  const operandAsObject = typeof operand === "object" && operand ? operand : {};
12
40
  const value = operand.value;
13
41
  let idx = constantsMap.get(value);
42
+ let key = 0;
14
43
  if (typeof idx !== "number") {
15
44
  idx = constants.length;
16
45
  constantsMap.set(value, idx);
17
- constants.push(value);
46
+ if (compiler.options.concealConstants && typeof value === "string") {
47
+ // Strings: position-dependent XOR. Key must be >= 1.
48
+ key = getRandomInt(1, U16_MAX);
49
+ constants.push(concealString(value, key));
50
+ } else if (compiler.options.concealConstants && typeof value === "number" && Number.isInteger(value)) {
51
+ // Integers: simple XOR. Result is still a valid JS integer.
52
+ key = getRandomInt(1, U16_MAX);
53
+ constants.push(value ^ key);
54
+ } else {
55
+ // Not concealable (null, undefined, boolean, float, RegExp…) or option off.
56
+ key = 0;
57
+ constants.push(value);
58
+ }
59
+ keyMap.set(idx, key);
60
+ } else {
61
+ // Reuse existing pool entry — same key that was assigned on first intern.
62
+ key = keyMap.get(idx);
18
63
  }
19
- const newOperand = {
64
+ const idxOperand = {
20
65
  ...operandAsObject,
21
66
  type: "number",
22
67
  resolvedValue: idx
23
68
  };
24
- return newOperand;
69
+ const keyOperand = {
70
+ ...operandAsObject,
71
+ type: "number",
72
+ resolvedValue: key
73
+ };
74
+
75
+ // key is a plain u16 number — no wrapping needed.
76
+ return [idxOperand, keyOperand];
25
77
  }
26
78
  const resolved = [];
27
79
  for (const instr of bc) {
28
80
  const [op, ...operands] = instr;
29
81
  const hasConstant = operands.some(o => o !== undefined && o !== null && typeof o === "object" && o.type === "constant");
30
82
  if (hasConstant) {
31
- const newOperands = operands.map(operand => operand?.type === "constant" ? intern(operand) : operand);
83
+ // 1-to-2 expansion: each {type:"constant"} becomes [constIdx, concealKey].
84
+ const newOperands = [];
85
+ for (const operand of operands) {
86
+ if (operand?.type === "constant") {
87
+ const [idxOperand, key] = intern(operand);
88
+ const newOperand = operand?.key ? key : idxOperand;
89
+ newOperands.push(newOperand);
90
+ // newOperands.push(key); // plain number — serialized as a regular u16 slot
91
+ } else {
92
+ newOperands.push(operand);
93
+ }
94
+ }
32
95
  const newInstr = [op, ...newOperands];
33
96
  newInstr[SOURCE_NODE_SYM] = instr[SOURCE_NODE_SYM];
34
97
  resolved.push(newInstr);
@@ -1,4 +1,5 @@
1
- import { choice } from "../utils/random-utils.js";
1
+ import { choice } from "../../utils/random-utils.js";
2
+ import { getInstructionSize } from "../../utils/op-utils.js";
2
3
  export function selfModifying(bc, compiler) {
3
4
  // Walk the bytecode looking for "defineLabel" pseudo-ops, which start basic
4
5
  // blocks. For each block we collect the body (instructions between the label
@@ -61,7 +62,7 @@ export function selfModifying(bc, compiler) {
61
62
  const patchLabel = `patch_${originalLabel}_${patchCount++}`;
62
63
 
63
64
  // Flat size of the body (each instruction occupies instr.length slots).
64
- const bodyFlatSize = body.reduce((acc, instr) => acc + instr.filter(x => x?.placeholder !== true).length, 0);
65
+ const bodyFlatSize = body.reduce((acc, instr) => acc + getInstructionSize(instr), 0);
65
66
 
66
67
  // ── PATCH instruction (4 flat slots: opcode + 3 operands) ───────────
67
68
  // destPc = originalLabel + 4 (slot right after PATCH's 4 slots)
@@ -1,12 +1,14 @@
1
1
  import { SOURCE_NODE_SYM } from "../../compiler.js";
2
- import { nextFreeSlot, U16_MAX } from "../utils/op-utils.js";
2
+ import { getInstructionSize, nextFreeSlot, U16_MAX } from "../../utils/op-utils.js";
3
3
 
4
4
  // Creates specialized opcodes for the most frequent (OPCODE + single_integer_operand) pairs.
5
5
  // Example: [OP.LOAD_CONST, 1] becomes [SPECIALIZED_LOAD_CONST_1].
6
6
  // Only instructions with *exactly one numeric operand* are considered.
7
- // MAKE_CLOSURE and any instruction with zero / multiple operands are skipped.
7
+ // MAKE_CLOSURE and other N-sized instructions cannot be specialized
8
8
  // Runs after selfModifying but before resolveLabels (operands stay plain numbers).
9
9
  export function specializedOpcodes(bc, compiler) {
10
+ const disallowedOps = new Set([compiler.OP.MAKE_CLOSURE, compiler.OP.BUILD_ARRAY, compiler.OP.BUILD_OBJECT, compiler.OP.CALL, compiler.OP.CALL_METHOD, compiler.OP.NEW]);
11
+
10
12
  // ── Collect used opcodes exactly as specified ─────────────────────────────
11
13
  const usedOpcodes = new Set(Object.keys(compiler.OP_NAME).map(k => parseInt(k, 10)).filter(v => !isNaN(v)));
12
14
  if (usedOpcodes.size > U16_MAX) return {
@@ -17,26 +19,29 @@ export function specializedOpcodes(bc, compiler) {
17
19
  const freqMap = new Map();
18
20
  for (const instr of bc) {
19
21
  const op = instr[0];
20
- if (op === null || op === compiler.OP.MAKE_CLOSURE) continue;
22
+ if (op === null || disallowedOps.has(op)) continue;
21
23
 
22
- // Must have exactly one operand and it must be a plain number
23
- if (instr.length !== 2) continue;
24
- const operand = instr[1];
25
- const key = `${op},${operand}`;
24
+ // Only supports between 1-6 operands
25
+ const operandCount = getInstructionSize(instr) - 1;
26
+ if (operandCount < 1 || operandCount > 6) continue;
27
+ const operands = instr.slice(1);
28
+ const operandsKey = JSON.stringify(operands);
29
+ const key = `${op},${operandsKey}`;
26
30
  const entry = freqMap.get(key);
27
31
  if (entry) {
28
- entry.count++;
32
+ entry.occurences++;
29
33
  } else {
30
34
  freqMap.set(key, {
31
35
  op,
32
- operand,
33
- count: 1
36
+ operands,
37
+ operandsKey,
38
+ occurences: 1
34
39
  });
35
40
  }
36
41
  }
37
42
 
38
43
  // ── Step 2: keep combinations that appear >= 2 times, sort by frequency ───
39
- const candidates = Array.from(freqMap.values()).filter(e => e.count >= 1).sort((a, b) => b.count - a.count);
44
+ const candidates = Array.from(freqMap.values()).filter(e => e.occurences >= 1).sort((a, b) => b.occurences - a.occurences);
40
45
  if (candidates.length === 0) return {
41
46
  bytecode: bc
42
47
  };
@@ -49,18 +54,19 @@ export function specializedOpcodes(bc, compiler) {
49
54
  if (specialOp === -1) break;
50
55
  const {
51
56
  op: originalOp,
52
- operand
57
+ operands,
58
+ operandsKey
53
59
  } = candidates[i];
54
- const key = `${originalOp},${JSON.stringify(operand)}`;
60
+ const key = `${originalOp},${operandsKey}`;
55
61
  sigToSpecial.set(key, specialOp);
56
62
  specializedOps[specialOp] = {
57
63
  originalOp,
58
- operand
64
+ operands
59
65
  };
60
66
 
61
67
  // Register a human-readable name for disassembly / debugging
62
68
  const originalName = compiler.OP_NAME[originalOp] ?? `OP_${originalOp}`;
63
- compiler.OP_NAME[specialOp] = `${originalName}_${JSON.stringify(operand)}`;
69
+ compiler.OP_NAME[specialOp] = `${originalName}_${JSON.stringify(operandsKey)}`;
64
70
  }
65
71
 
66
72
  // Store mapping so the interpreter knows how to dispatch the specialized op
@@ -70,15 +76,20 @@ export function specializedOpcodes(bc, compiler) {
70
76
  const result = [];
71
77
  for (const instr of bc) {
72
78
  const op = instr[0];
73
- // Only consider instructions with exactly one numeric operand
74
- if (op === null || instr.length !== 2 || op === compiler.OP.MAKE_CLOSURE) {
79
+ // Only consider instructions with one or more operands
80
+ if (op === null || instr.length <= 1 || op === compiler.OP.MAKE_CLOSURE) {
81
+ result.push(instr);
82
+ continue;
83
+ }
84
+ const operands = instr.slice(1);
85
+ const operandsKey = JSON.stringify(operands);
86
+ const key = `${op},${operandsKey}`;
87
+ const specialOpCode = sigToSpecial.get(key);
88
+ if (!specialOpCode) {
75
89
  result.push(instr);
76
90
  continue;
77
91
  }
78
- const operand = instr[1];
79
- const key = `${op},${JSON.stringify(operand)}`;
80
- if (sigToSpecial.has(key)) {
81
- const specialOpCode = sigToSpecial.get(key);
92
+ const newOperands = operands.map(operand => {
82
93
  const operandAsObject = typeof operand === "object" && operand ? operand : {
83
94
  type: "number",
84
95
  value: operand,
@@ -88,14 +99,13 @@ export function specializedOpcodes(bc, compiler) {
88
99
  ...operandAsObject,
89
100
  placeholder: true
90
101
  };
91
- const newInstr = [specialOpCode, newOperand];
102
+ return newOperand;
103
+ });
104
+ const newInstr = [specialOpCode, ...newOperands];
92
105
 
93
- // Preserve source-node information for error reporting
94
- newInstr[SOURCE_NODE_SYM] = instr[SOURCE_NODE_SYM];
95
- result.push(newInstr);
96
- } else {
97
- result.push(instr);
98
- }
106
+ // Preserve source-node information for error reporting
107
+ newInstr[SOURCE_NODE_SYM] = instr[SOURCE_NODE_SYM];
108
+ result.push(newInstr);
99
109
  }
100
110
  return {
101
111
  bytecode: result
@@ -0,0 +1,134 @@
1
+ import * as t from "@babel/types";
2
+ import traverseImport from "@babel/traverse";
3
+ import { ok } from "assert";
4
+ const traverse = traverseImport.default || traverseImport;
5
+
6
+ // Extract the real statement list from a SwitchCase consequent.
7
+ function extractCaseBody(switchCase) {
8
+ let stmts;
9
+ if (switchCase.consequent.length === 1 && t.isBlockStatement(switchCase.consequent[0])) {
10
+ stmts = switchCase.consequent[0].body;
11
+ } else {
12
+ stmts = switchCase.consequent;
13
+ }
14
+ return stmts.filter(s => !t.isBreakStatement(s) && !t.isEmptyStatement(s));
15
+ }
16
+
17
+ // Replace every `this._operand()` call in bodyStmts with `_operands[i]`
18
+ // where i is the call's sequential index (0-based).
19
+ // Returns the number of replacements performed.
20
+ function replaceOperandCalls(bodyStmts) {
21
+ let replaced = 0;
22
+ traverse(t.blockStatement(bodyStmts), {
23
+ noScope: true,
24
+ CallExpression(path) {
25
+ const callee = path.node.callee;
26
+ const isMethodCall = methodName => {
27
+ return t.isMemberExpression(callee) && t.isThisExpression(callee.object) && t.isIdentifier(callee.property, {
28
+ name: methodName
29
+ }) && path.node.arguments.length === 0;
30
+ };
31
+
32
+ // Replace with _operands[i]
33
+ const createOperandAccess = () => {
34
+ return t.memberExpression(t.identifier("_operands"), t.numericLiteral(replaced++), true // computed
35
+ );
36
+ };
37
+ if (isMethodCall("_operand")) {
38
+ path.replaceWith(createOperandAccess());
39
+ }
40
+ if (isMethodCall("_constant")) {
41
+ path.node.arguments = [createOperandAccess(), createOperandAccess()];
42
+ }
43
+ }
44
+ });
45
+ return replaced;
46
+ }
47
+
48
+ // Appends a generated switch case for every entry in compiler.ALIASED_OPS.
49
+ // Each alias case:
50
+ // 1. Reads all operands eagerly into `_unsortedOperands` (in the shuffled
51
+ // bytecode order) via sequential this._operand() calls.
52
+ // 2. Restores the original operand order into `_operands` using the INVERSE
53
+ // of the stored `order` permutation:
54
+ // inverseOrder[order[i]] = i
55
+ // _operands[j] = _unsortedOperands[inverseOrder[j]]
56
+ // This is necessary because the bytecode stored originalOperands[order[i]]
57
+ // at slot i, so recovering originalOperands[j] requires the inverse lookup.
58
+ // 3. Executes a clone of the original handler body where every
59
+ // this._operand() has been replaced by the corresponding `_operands[i]`.
60
+ //
61
+ // Must run AFTER applyMacroOpcodes / applySpecializedOpcodes (so original
62
+ // cases already exist) but BEFORE applyShuffleOpcodes (so the new alias
63
+ // cases are also shuffled into the handler order).
64
+ export function applyAliasedOpcodes(ast, compiler) {
65
+ if (!compiler.ALIASED_OPS || Object.keys(compiler.ALIASED_OPS).length === 0) return;
66
+ let switchStatement = null;
67
+ traverse(ast, {
68
+ SwitchStatement(path) {
69
+ if (path.node.leadingComments?.some(c => c.value.includes("@SWITCH"))) {
70
+ switchStatement = path.node;
71
+ path.stop();
72
+ }
73
+ }
74
+ });
75
+ ok(switchStatement, "Could not find @SWITCH statement for aliased opcodes");
76
+
77
+ // Build opName → SwitchCase map from existing OP.xxx case tests.
78
+ const nameToCaseMap = new Map();
79
+ for (const sc of switchStatement.cases) {
80
+ const test = sc.test;
81
+ if (test && t.isMemberExpression(test) && t.isIdentifier(test.object, {
82
+ name: "OP"
83
+ }) && t.isIdentifier(test.property)) {
84
+ nameToCaseMap.set(test.property.name, sc);
85
+ }
86
+ }
87
+ for (const [aliasOpStr, info] of Object.entries(compiler.ALIASED_OPS)) {
88
+ const aliasOpCode = Number(aliasOpStr);
89
+ const {
90
+ originalOp,
91
+ order
92
+ } = info;
93
+ const arity = order.length;
94
+ const originalName = compiler.OP_NAME[originalOp];
95
+ if (!originalName) continue;
96
+ const originalCase = nameToCaseMap.get(originalName);
97
+ if (!originalCase) continue;
98
+
99
+ // Clone the original handler body (deep clone so we don't mutate the source)
100
+ const bodyStmts = extractCaseBody(originalCase).map(s => t.cloneNode(s, true));
101
+
102
+ // Replace this._operand() calls with _operands[i]
103
+ const replaced = replaceOperandCalls(bodyStmts);
104
+
105
+ // If the handler has a different number of _operand() calls than our
106
+ // recorded arity, skip this alias (variable-operand handler guard).
107
+ if (replaced !== arity) continue;
108
+
109
+ // Build: var _unsortedOperands = [this._operand(), this._operand(), ...]
110
+ // Reads operands in the NEW (shuffled) bytecode order.
111
+ const unsortedInit = t.variableDeclaration("let", [t.variableDeclarator(t.identifier("_unsortedOperands"), t.arrayExpression(Array.from({
112
+ length: arity
113
+ }, () => t.callExpression(t.memberExpression(t.thisExpression(), t.identifier("_operand")), []))))]);
114
+
115
+ // The inverse permutation maps original position j → unsorted index i,
116
+ // because the bytecode stored originalOperands[order[i]] at slot i.
117
+ // inverseOrder[j] = i means: original operand j lives at _unsortedOperands[i]
118
+ const inverseOrder = new Array(arity);
119
+ for (let i = 0; i < arity; i++) {
120
+ inverseOrder[order[i]] = i;
121
+ }
122
+
123
+ // Build: var _operands = [_unsortedOperands[inverseOrder[0]], ...]
124
+ // Restores the original operand order expected by the handler body.
125
+ const operandsInit = t.variableDeclaration("let", [t.variableDeclarator(t.identifier("_operands"), t.arrayExpression(inverseOrder.map(idx => t.memberExpression(t.identifier("_unsortedOperands"), t.numericLiteral(idx), true // computed
126
+ ))))]);
127
+ const allStmts = [unsortedInit, operandsInit, ...bodyStmts];
128
+
129
+ // Add a leading comment for readability in non-minified output
130
+ t.addComment(allStmts[0], "leading", ` ${compiler.OP_NAME[aliasOpCode]} (order: [${order.join(",")}])`, true);
131
+ allStmts.push(t.breakStatement());
132
+ switchStatement.cases.push(t.switchCase(t.numericLiteral(aliasOpCode), [t.blockStatement(allStmts)]));
133
+ }
134
+ }
@@ -1,6 +1,6 @@
1
1
  import traverseImport from "@babel/traverse";
2
2
  import { ok } from "assert";
3
- import { shuffle } from "../utils/random-utils.js";
3
+ import { shuffle } from "../../utils/random-utils.js";
4
4
  const traverse = traverseImport.default || traverseImport;
5
5
 
6
6
  // Randomly reorder the switch cases inside the @SWITCH statement so the