Download Game! Currently 65 players and visitors. Last logged in:VihaanSolvikAmonraTigerlily

Blitzer's Blog >> 72222

Back to blogs index
Posted: 10 Sep 2026 04:24 [ permalink ]
To transition from an interpreter to a compiler, you shift your target from an
environment to a Stack Machine. Instead of evaluating the result immediately,
the tree-walking function performs a post-order traversalmeaning it visits the
children (the arguments) before visiting the parent (the operator).
This flattens the nested JSON array into a sequence of instructions (opcodes).
The arguments are pushed onto a virtual stack, and the operators consume them
from that stack.
Here is how you adapt the tree-walker to emit a flat list of VM opcodes in
JavaScript/Node.js, including the critical technique of "jump patching" for
control flow.
The Bytecode Compiler
const Opcodes = {
  CONST: 'OP_CONST',         // Push a constant value to the stack
  LOAD: 'OP_LOAD',           // Load a variable from scope to the stack
  ADD: 'OP_ADD',             // Pop 2, Add, Push result
  MUL: 'OP_MUL',             // Pop 2, Multiply, Push result
  JMP_FALSE: 'OP_JMP_FALSE', // Pop 1, Jump to address if false
  JMP: 'OP_JMP'              // Jump to address unconditionally
};

function compile(ast, bytecode = []) {
  // 1. Base cases: Emit push instructions for leaves
  if (!Array.isArray(ast)) {
    if (typeof ast === 'number') {
      bytecode.push({ op: Opcodes.CONST, val: ast });
    } else if (typeof ast === 'string') {
      bytecode.push({ op: Opcodes.LOAD, val: ast });
    }
    return bytecode;
  }

  const [op, ...args] = ast;

  // 2. Special Forms: Control Flow (Requires Jump Patching)
  if (op === 'if') {
    const [condition, thenBranch, elseBranch] = args;
    
    // Compile condition (leaves result on top of stack)
    compile(condition, bytecode);
    
    // Emit JMP_FALSE with a placeholder address
    const jmpFalseInst = { op: Opcodes.JMP_FALSE, val: null };
    bytecode.push(jmpFalseInst);
    
    // Compile the 'then' branch
    compile(thenBranch, bytecode);
    
    // Emit unconditional JMP over the 'else' branch
    const jmpInst = { op: Opcodes.JMP, val: null };
    bytecode.push(jmpInst);
    
    // Patch the JMP_FALSE to point to the start of the 'else' branch
    jmpFalseInst.val = bytecode.length;
    
    // Compile the 'else' branch
    compile(elseBranch, bytecode);
    
    // Patch the unconditional JMP to point to the end of the statement
    jmpInst.val = bytecode.length;
    
    return bytecode;
  }

  // 3. Standard Operations: Post-Order Traversal
  // Compile arguments first (pushes them onto the VM stack in order)
  args.forEach(arg => compile(arg, bytecode));

  // Emit the operator opcode last
  switch (op) {
    case '+': bytecode.push({ op: Opcodes.ADD }); break;
    case '*': bytecode.push({ op: Opcodes.MUL }); break;
    default: throw new Error(`Unknown operator: ${op}`);
  }

  return bytecode;
}

How the Flattening Works
If you feed this compiler the AST ["+", 1, ["*", 2, 3]], it traverses to the
deepest nodes first and emits this flat array:
 * { op: 'OP_CONST', val: 1 }
 * { op: 'OP_CONST', val: 2 }
 * { op: 'OP_CONST', val: 3 }
 * { op: 'OP_MUL' }
 * { op: 'OP_ADD' }
During execution, the VM loops through this array. It pushes 1, 2, and 3 onto
its internal stack. When it hits OP_MUL, it pops 3 and 2, multiplies them, and
pushes 6. When it hits OP_ADD, it pops 6 and 1, adds them, and pushes 7.
Architectural Advantages for this Design
 * Trivial Telemetry: Because the bytecode is a flat array of objects,
implementing bytecode telemetry logging for the engine is as simple as
attaching a sourceNode or line property to the instruction objects during
compilation.
 * Cache Locality: A flat array is highly cache-friendly. The VM's main
execution loop (often a giant switch statement inside a while loop) can
iterate through instructions sequentially without chasing pointers through a
heavily nested memory structure.
 * Decoupled Frontend: Your Fastify gateway or REST API can receive the
JSON-Lisp AST, compile it down to this flat format in milliseconds, and pass
the raw bytecode over a high-performance channel (like an SPSC Ring Buffer) to
a dedicated worker thread or separate VM process.