TAC IR

Format

TAC uses virtual registers (%r0, %r1, %r2, …) with explicit assignments:

plaintext
%r0 = const_int  10
%r1 = const_int  20
%r2 = add        %r0, %r1
      arg        %r2, 0
%r3 = call       @print_i64, argc=1

Every instruction produces at most one result in a named register. Instructions without a result (like arg, store, jump) have dest = TAC_REG_NONE.

Instruction encoding

Each instruction is a fixed-size struct:

FieldTypePurpose
optac_opcodeOperation kind
destuint32_tDestination register (or TAC_REG_NONE)
atac_operandFirst operand (register or immediate)
btac_operandSecond operand (register or immediate)
extrauint32_tOpcode-specific data (arg count, else-block, field offset, etc.)
loctape_source_locSource location for debug info

Operands are either a register reference (TAC_OP_REG), an immediate value (TAC_OP_IMM), or absent (TAC_OP_NONE).

Basic blocks

Instructions are grouped into basic blocks. Each block has a linear array of instructions and ends with a terminator (jump, branch, or ret):

plaintext
fn example(x: i64) -> void:
  bb0:
    %r0 = load       local[0]
    %r1 = const_int  0
    %r2 = cmp_gt     %r0, %r1
          branch     %r2, bb1, bb2

  bb1:
    %r3 = const_str  "positive"
          arg        %r3, 0
          call       @println, argc=1
          jump       -> bb3

  bb2:
    %r4 = const_str  "non-positive"
          arg        %r4, 0
          call       @println, argc=1
          jump       -> bb3

  bb3:
          ret

Opcodes

Constants

OpcodeSemantics
const_intdest = imm (64-bit integer literal)
const_strdest = strings[imm] (index into module string table)

Arithmetic

OpcodeSemantics
adddest = a + b
subdest = a - b
muldest = a * b
divdest = a / b
moddest = a % b

Opcodes are type-agnostic — the register’s tape_rtype_kind determines whether the backend emits integer or float instructions.

Bitwise

OpcodeSemantics
shldest = a << b
shrdest = a >> b
anddest = a & b
ordest = a | b
xordest = a ^ b

Comparison

OpcodeSemantics
cmp_eqdest = (a == b)
cmp_nedest = (a != b)
cmp_ltdest = (a < b)
cmp_ledest = (a <= b)
cmp_gtdest = (a > b)
cmp_gedest = (a >= b)

Memory

OpcodeSemantics
loaddest = locals[imm] (load from local slot by index)
storelocals[imm] = b (store to local slot)
allocadest = stack_alloc(imm bytes)
local_addrdest = &locals[imm] (address of local slot)
load_fielddest = *(a + b) (load from base + byte offset b; extra = access size, | 0x100 to sign-extend)
store_field*(a + offset) = b (store to base + byte offset; extra = offset | size<<28)
load_ptrdest = *a (dereference pointer; extra = element size)
store_ptr*a = b (write through pointer; extra = element size)
load_volatiledest = *a, a volatile access (extra = size): exactly once, in order, never removed, moved or merged by any pass
store_volatile*a = b, a volatile access (extra = size)
atomic_loaddest = *a, one atomic, sequentially consistent load (extra = size, 4 or 8): @atomic_load
atomic_store*a = b, an atomic store (extra = size): @atomic_store
atomic_adddest = *a; *a = dest + b as one atomic operation, wrapping; dest the value before (@atomic_add)
atomic_casdest = (*a == b), and then *a = c when equal, as one atomic operation (c, the new value, is the instruction’s third register; dest a bool): @atomic_cas

Every pass treats the four atomic opcodes as it treats the volatile ones: each runs exactly once where it stands, its value used or not, and no other memory access or call moves across one. Natively each is a fixed instruction sequence (mov, xchg, lock xadd, lock cmpxchg on x86-64; ldar, stlr and ldaxr / stlxr loops on arm64), the same at -O0; the VM performs the plain operation. | move_bytes | memmove(dest, a, b): dest the destination’s address (read, not written), a the source, b the length (@move_bytes) | | fill_bytes | memset(dest, a, b): a the byte (its low 8 bits), b the length (@fill_bytes) | | heap_alloc | dest = heap_alloc(extra bytes) (zeroed allocation; with a register operand, the size comes from a) | | heap_free | heap_free(a) (give back a block heap_alloc returned) | | load_global | dest = globals[a.imm] (module-level variable) | | store_global | globals[a.imm] = b | | global_addr | dest = &globals[a.imm] (address of a module-level variable, for arrays and structs) |

Control flow

OpcodeSemantics
jumpUnconditional branch to a.imm (block id)
branchIf a then b.imm else extra (conditional branch to two blocks)
calldest = fn[a.imm](...) (extra = argument count)
call_inddest = a(...) (indirect call through function pointer; extra = arg count)
fn_addrdest = &fn[a.imm] (take address of function)
argPush a as argument b.imm for next call
retReturn a (or void if a is none)
trapRuntime trap: write the message strings[a.imm] (trap: <what> at file:line:col) to stderr and exit with 101. The VM executes it; native code generation first rewrites it into a call to a per-target __tape_trap

String operations

OpcodeSemantics
string_copydest = deep_copy(a)
string_dropfree(a) if dynamic (cap > 0)
string_concatdest = a + b (always allocates; extra bytes of room reserved past the two, which an interpolation’s later pieces append into)
string_appenddest = append(a, b) (reallocs a’s buffer)
string_eqdest = (a == b) (byte comparison)
int_to_strdest = decimal_repr(a) (extra 1: a is a u64; 2: the digits in the instruction’s own frame storage, a borrowed descriptor, cap 0: an interpolation’s {value})
mulhidest = the high 64 bits of the 128-bit a * b (extra 1 unsigned, 0 signed: @mul_hi)
addcdest = the low 64 bits of a + b + (c != 0), or a - b - (c != 0) with extra bit 0; with extra bit 1 the carry (borrow) out instead, 0 or 1. c is a third register operand (@add_carry, @sub_borrow)

Type operations

OpcodeSemantics
castdest = cast(a) (extra = src_type | dst_type<<8)

Vectors and processor features

OpcodeSemantics
vec*dest = op(*a, *b) on 128-bit vectors (extra = vector op | lane type<<8): lane arithmetic, compares, shifts, splat, widen/narrow, AES and carry-less multiply
vec_getdest = a scalar of the vector at a: one lane, any/all lanes set, smallest/largest lane
cpu_featuresdest = the running processor’s feature mask (@cpu_has)
cpuidx86-64 only: cpuid leaf query, produced when cpu_features is lowered
asmA #bytecode function’s whole body: its machine code bytes

Functions

Each TAC function contains:

FieldPurpose
name / name_lenFunction name
param_countNumber of parameters (registers 0..n-1)
local_countNumber of local variable slots
next_regNext available virtual register
blocksArray of basic blocks
reg_typesType of each virtual register
local_typesType of each local slot
return_typeFunction return type
link_libDLL/library name for extern functions
symbol_nameExplicit linker symbol (for @export)
is_exportWhether this function appears in the export table

Parameters occupy registers %r0 through %r(param_count-1). Local variables are accessed by slot index via load/store.

Module

A tac_module contains:

  • functions[] — all compiled functions (own module + dependencies)
  • strings[] — deduplicated string literal table (and const tables)
  • globals[] — module-level variables
  • root_fn_offset / root_fn_count — which functions belong to the root module (vs dependencies)

Register typing

Every virtual register and local slot has an associated tape_rtype_kind:

KindSizeDescription
void0No value
bool1Boolean
i8/u818-bit integer
i16/u16216-bit integer
i32/u32432-bit integer
i64/u64864-bit integer
f32432-bit float
f64864-bit float
ptr8Pointer
fn_ptr8Function pointer
string24String (ptr + len + cap)
slice16Slice (ptr + len)
structvariesStruct (passed by pointer in registers)
optionalvariesOptional (8-byte tag + value; 16 for a scalar)
falliblevariesResult type (tag + max(ok, err))
taggedvariesTagged union
enumvariesEnum (its backing integer)
arrayvariesFixed array [N]T
vec16128-bit vector (u8x16 .. i64x2)
color8Color value (packed RGBA)

Wide values

Types larger than 64 bits (structs, strings, slices) are stack-allocated via alloca. The register holds a pointer to the stack slot:

plaintext
%r0 = alloca      24          // allocate 24 bytes for Vec3
      store_field %r0, %r1    // off=0: x
      store_field %r0, %r2    // off=8: y
      store_field %r0, %r3    // off=16: z

Why not stack-based bytecode?

Tape 1.0 used stack-machine bytecode. Problems:

  • Reverse-engineering value flow for native codegen
  • Phantom operand bugs
  • Undebuggable calling conventions

TAC eliminates all of these by making data flow explicit. Both the VM interpreter and native backends read the same representation without needing a decompilation step.

Last modified: