This page describes the current SSA builder and backend implementation. Its unversioned esp examples are compatibility snapshots, still accepted by the reader. Start with the new text walkthrough for esper 1 syntax and the roadmap for the library-first migration boundary.

Every other guide writes .esp text and lets the parser turn it into IR. A real frontend does not do that. It holds an AST, and it wants to produce the IR directly. That is what the builder API is for: the parser is one of its clients, not a layer above it. That is meant literally. src/text/parser.c builds every function, block, instruction, global, constant and type through the calls on this page, so a unit the parser produces is one a frontend could have constructed call for call.

Nothing on this page goes through the text format, and nothing includes a private header. The whole surface is in esper.h under MARK: In-Memory Builder.

The shape of a build

A build has four moves, in this order:

  1. esper_unit_new creates the unit that owns everything.
  2. esper_build_begin opens a function and hands back an EsperBuilder.
  3. Blocks are created, bound, and filled with instructions.
  4. esper_build_end closes the function, and esper_builder_free releases the builder.

The builder is per-function. A unit with three functions is three begin/end pairs against the same unit. The unit outlives all of them, and esper_unit_free releases every function built into it.

Errors accumulate rather than aborting. Once the builder records a mistake, every emitter returns ESP_VAL_NONE and does nothing, so a frontend can emit a whole function and check once at the end. That is why the first error is the one worth reading: later ones are usually its consequences.

A worked example

We will build two functions and then run the result three ways: printed as text, executed on the interpreter, and emitted as C.

func add(a: Int32, b: Int32) -> Int32 {
    return a + b
}

func main() -> Int32 {
    var sum: Int32 = 0
    for i in 1..<11 { sum = add(sum, i) }
    return sum          // 55
}

The straight-line function

add needs one block and three instructions. esper_build_param gives the SSA id of a parameter, which is just its index, and esper_build_binary covers every two-operand opcode: arithmetic, bitwise, shift, and comparison.

static void esper_doc_build(EsperUnit *unit) {
  EsperTypeRef params[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
  EsperTypeRef rets[1] = {ESP_TYPE_I32};

  EsperBuilder *b = esper_build_begin(unit, "add", params, 2, rets, 1);
  esper_build_bind(b, esper_build_block(b, "entry"));

  EsperVal sum = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I32,
                                    esper_build_param(b, 0),
                                    esper_build_param(b, 1));
  esper_build_ret(b, &sum, 1);

  if (!esper_build_end(b))
    fprintf(stderr, "add: %s\n", esper_builder_last_error(b));
  esper_builder_free(b);
}
func $add(%0 i32, %1 i32) -> i32 {
entry:
  %2 = add i32 %0, %1
  ret i32 %2
}

esper_build_end returns NULL when anything went wrong, and esper_builder_last_error holds the text of the first mistake. The function belongs to the unit either way, so there is nothing to free on failure beyond the builder itself.

The loop, and the values it carries

The loop is the interesting half, because a loop header is entered from a block that has not been written yet. Esper handles this by separating a block’s creation from its filling: every block is created up front, so a branch can name one that does not exist yet. The values the header takes are declared on the header, and each branch into it hands them over, so the back edge is written where the values it carries exist.

static void build_main(EsperUnit *unit) {
  EsperTypeRef rets[1] = {ESP_TYPE_I32};
  EsperBuilder *b = esper_build_begin(unit, "main", NULL, 0, rets, 1);

  // Created up front, so the branches below can name them.
  EsperBlockId entry  = esper_build_block(b, "entry");
  EsperBlockId header = esper_build_block(b, "header");
  EsperBlockId body   = esper_build_block(b, "body");
  EsperBlockId done   = esper_build_block(b, "done");

  esper_build_bind(b, entry);
  EsperVal zero = esper_build_ldi(b, ESP_TYPE_I32, 0);
  EsperVal one  = esper_build_ldi(b, ESP_TYPE_I32, 1);
  EsperVal seed[2] = {zero, one};
  esper_build_jmp_args(b, header, seed, 2);

  esper_build_bind(b, header);
  EsperVal sum   = esper_build_block_param(b, ESP_TYPE_I32);
  EsperVal i     = esper_build_block_param(b, ESP_TYPE_I32);
  EsperVal limit = esper_build_ldi(b, ESP_TYPE_I32, 11);
  EsperVal test  = esper_build_binary(b, ESP_OP_LT_S, ESP_TYPE_I32, i, limit);
  esper_build_jmp_if(b, test, body, done);

  esper_build_bind(b, body);
  EsperVal args[2] = {sum, i};
  EsperVal next_sum = esper_build_call(b, ESP_TYPE_I32, "add", args, 2);
  EsperVal step   = esper_build_ldi(b, ESP_TYPE_I32, 1);
  EsperVal next_i = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I32, i, step);

  // The back edge, written where the values it carries exist.
  EsperVal carry[2] = {next_sum, next_i};
  esper_build_jmp_args(b, header, carry, 2);

  esper_build_bind(b, done);
  esper_build_ret(b, &sum, 1);

  if (!esper_build_end(b))
    fprintf(stderr, "main: %s\n", esper_builder_last_error(b));
  esper_builder_free(b);
}

Two rules govern block parameters. A parameter binds by position from the block’s first instruction, so every one of them must be declared before anything else in the block, which is why both come before the ldi. And every branch into the block carries one argument per parameter, in the order they were declared; esper_verify reports a branch that carries the wrong number.

esper_build_call looks its callee up by name, so add has to exist by the time main is built. Building it first is the simplest way; esper_func_declare is the other, and is what mutual recursion needs.

Driving it

int main(void) {
  EsperUnit *unit = esper_unit_new("sum_to_ten");

  build_add(unit);
  build_main(unit);

  esper_print(unit, stdout);
  printf("main() = %d\n", esper_exec(unit, "main"));
  esper_emit_c(unit, stdout);

  esper_unit_free(unit);
  return 0;
}

The printed IR is worth reading closely, because it shows the id numbering the next section is about:

unit "sum_to_ten"

func $add(%0 i32, %1 i32) -> i32 {
entry:
  %2 = add i32 %0, %1
  ret i32 %2
}

func $main() -> i32 {
entry:
  %0 = ldi i32 0
  %1 = ldi i32 1
  jmp header(%0, %1)

header(%3 i32, %4 i32):
  %5 = ldi i32 11
  %6 = lt.s i32 %4, %5
  jmp.if %6 body done

body:
  %8 = call i32 $add, %3, %4
  %9 = ldi i32 1
  %10 = add i32 %4, %9
  jmp header(%8, %10)

done:
  ret i32 %3
}

esper_exec returns 55. The C the backend emits compiles as C99 and returns 55 as its exit status:

int32_t main(void) {
    /* ... */
main_header:;
    v5 = (int32_t)11;
    v6 = ((int32_t)v4 < (int32_t)v5) ? 1 : 0;
    if (v6) {
        goto main_body;
    } else {
        goto main_done;
    }
main_body:;
    v8 = add(v3, v4);
    v9 = (int32_t)1;
    v10 = (int32_t)(v4 + v9);
    { int32_t _arg3 = v8; int32_t _arg4 = v10; v3 = _arg3; v4 = _arg4; }
    goto main_header;
main_done:;
    return v3;
}

The arguments became a pair of temporaries copied in the predecessor, which is the standard lowering and the reason the temporaries exist: v3 and v4 must be read before either is overwritten.

A version of this program lives at examples/build.c and runs under task api-test.

The calls a merge uses

A merge is a block parameter: the block declares what it takes, and each branch into it hands over the arguments. Three calls reach the form:

EsperVal esper_build_block_param(EsperBuilder *b, EsperTypeRef type);

void esper_build_jmp_args(EsperBuilder *b, EsperBlockId target,
                          const EsperVal *args, uint32_t count);

void esper_build_jmp_if_args(EsperBuilder *b, EsperVal cond,
                             EsperBlockId if_true, const EsperVal *true_args,
                             uint32_t true_count, EsperBlockId if_false,
                             const EsperVal *false_args,
                             uint32_t false_count);

A conditional branch takes one argument list per arm, since the two arms enter different blocks and so bind different parameters. With no arguments, esper_build_jmp_args is esper_build_jmp and esper_build_jmp_if_args is esper_build_jmp_if.

A diamond needs no back-patching at all, because the value travels on the branch rather than in a list the merge block holds:

EsperBlockId entry = esper_build_block(b, "entry");
EsperBlockId left  = esper_build_block(b, "left");
EsperBlockId right = esper_build_block(b, "right");
EsperBlockId merge = esper_build_block(b, "merge");

esper_build_bind(b, entry);
EsperVal lo = esper_build_ldi(b, ESP_TYPE_I32, 20);
EsperVal hi = esper_build_ldi(b, ESP_TYPE_I32, 42);
EsperVal c  = esper_build_binary(b, ESP_OP_GT_S, ESP_TYPE_I32, hi, lo);
esper_build_jmp_if(b, c, left, right);

esper_build_bind(b, left);
esper_build_jmp_args(b, merge, &hi, 1);

esper_build_bind(b, right);
esper_build_jmp_args(b, merge, &lo, 1);

esper_build_bind(b, merge);
EsperVal p = esper_build_block_param(b, ESP_TYPE_I32);
esper_build_ret(b, &p, 1);

That prints as the following, and esper_exec returns 42:

func $main() -> i32 {
entry:
  %0 = ldi i32 20
  %1 = ldi i32 42
  %2 = gt.s i32 %1, %0
  jmp.if %2 left right

left:
  jmp merge(%1)

right:
  jmp merge(%0)

merge(%6 i32):
  ret i32 %6
}

A parameter is an instruction like any other, so it takes an SSA id by position: %6, because the two jumps above it own %4 and %5.

Two shapes are refused rather than encoded wrongly. A parameter on the entry block is an error, since the entry block is entered by calling the function and its values are the function’s own parameters. So is a parameter emitted after ordinary code, because arguments bind by position from the block’s first instruction and a parameter below anything else would bind nothing. Both report through esper_builder_error_count and esper_builder_last_error.

The argument count is not checked against the target, because the target may still be unbound when the branch is emitted. esper_verify reports a mismatch once the function is finished.

Which form to pick is a frontend’s choice, and nothing downstream prefers one: the parser, printer, interpreter, every backend and every optimization pass take either. A pass that has to build a merge of its own builds it in whichever form the function was already written in.

Expressions on the operand stack

Naming every intermediate is the tedious half of a frontend, and it is exactly the half an expression tree does not need names for. The esper_sb_* calls are a second way to drive the same builder: each one pops what it needs off an operand stack, emits the instruction the matching esper_build_* call would, and pushes what it made. Walking an expression tree becomes a post-order traversal with no bookkeeping.

// (a + b) * 2, where a and b are the function's parameters.
esper_sb_param(b, 0);
esper_sb_param(b, 1);
esper_sb_binary(b, ESP_OP_ADD, ESP_TYPE_I32);
esper_sb_ldi(b, ESP_TYPE_I32, 2);
esper_sb_binary(b, ESP_OP_MUL, ESP_TYPE_I32);
esper_sb_ret(b, 1);

This is a facade, not a second IR. The instructions that come out are the same ones, and the stack holds EsperVal, so the two styles mix freely: esper_sb_push puts a value built through the value API onto the stack, and esper_sb_pop takes one off to hand to a call that has no stack form.

esper_sb_param(b, 0);
esper_sb_ldi(b, ESP_TYPE_I32, 0);
esper_sb_binary(b, ESP_OP_NE, ESP_TYPE_I32);
esper_build_jmp_if(b, esper_sb_pop(b), body, done);

The first value pushed is the leftmost operand, so an expression is emitted in the order it is read. esper_sb_binary pops two and pushes one; esper_sb_select pops the condition, then the true value, then the false value; esper_sb_store pops an address and then a value and pushes nothing; esper_sb_call pops argc arguments and pushes the result unless the callee returns void. esper_sb_drop discards the top, for a statement whose value the source does not use, and esper_sb_depth reports how many values are held.

Stack callValue-API equivalentPopsPushes
esper_sb_ldiesper_build_ldi01
esper_sb_ldcesper_build_ldc01
esper_sb_paramesper_build_param01
esper_sb_unaryesper_build_unary11
esper_sb_binaryesper_build_binary21
esper_sb_selectesper_build_select31
esper_sb_loadesper_build_load11
esper_sb_storeesper_build_store20
esper_sb_global_getesper_build_global_get01
esper_sb_global_setesper_build_global_set10
esper_sb_callesper_build_callargc0 or 1
esper_sb_retesper_build_retcount0

The one rule is that the stack must be empty when a block is bound and when esper_build_end runs. Both check, and both report build.stack when values are left behind, along with how many. Popping an empty stack reports the same code and yields ESP_VAL_NONE.

The rule is deliberate rather than an implementation limit. A value carried across a control-flow edge is what a block parameter is for; letting the stack carry it would mean agreeing on its depth and its types at every join, which is a parameter list with the declaration left out. Keeping the stack inside one block is what makes this a facade over SSA rather than a stack machine with the hard part hidden.

Letting a pass place the merges

The loop above declares its own parameters, which is the direct thing to do when the frontend already knows where a value’s definitions merge. A frontend that does not want to know can emit a stack slot per local instead, with a store for every assignment and a load for every read, and declare no parameters at all.

There is no dedicated emitter for salloc; it is a one-operand instruction, so esper_build_unary reaches it:

EsperVal size = esper_build_ldi(b, ESP_TYPE_I32, 4);
EsperVal slot = esper_build_unary(b, ESP_OP_SALLOC, ESP_TYPE_PTR, size);
EsperVal zero = esper_build_ldi(b, ESP_TYPE_I32, 0);

esper_build_store(b, ESP_OP_STORE, ESP_TYPE_I32, slot, zero);
// ... later, in another block
EsperVal cur = esper_build_load(b, ESP_OP_LOAD, ESP_TYPE_I32, slot);

esper_mem2reg then promotes every slot that is only loaded from and stored to into SSA values, inserting the block parameters the control flow needs:

EsperConfig cfg = {0};
cfg.opt_mem2reg = true;
esper_optimize(unit, &cfg);

It runs first in the pipeline, ahead of the scalar passes, because what it produces is what they are written to work on. A slot survives as memory when its address is used as anything other than the address operand of a plain load or store, when the accesses disagree about the type, or when a load can be reached before any store has run. Optimization Passes has the full rule.

Volatile accesses

An address that names a device register rather than memory wants the volatile form, which is a separate emitter rather than a flag argument so no existing call site has to change:

esper_build_store_volatile(b, ESP_OP_STORE, ESP_TYPE_I32, reg, value);
EsperVal status = esper_build_load_volatile(b, ESP_OP_LOAD, ESP_TYPE_I32, reg);

Nothing may delete a volatile access, merge two of them, or move one out of a loop, and a slot touched by one is never promoted. esper_inst_is_volatile() reads the flag back off an instruction, which a consumer lowering the IR has to carry through: dropping it turns a device register access into an ordinary one, which is the bug the flag exists to prevent. Every backend lowers the flag.

Traps, bulk memory, and bit reversal

A bounds check needs somewhere to branch to, and both terminators take no operands:

esper_build_trap(b);          // stop the program
esper_build_unreachable(b);   // assert control never arrives here

Use esper_build_trap when the language says execution must not continue, and esper_build_unreachable when the frontend has already proved the path impossible. Reaching an unreachable is undefined, which is what lets control-flow simplification fold away the branch arm that leads to one. Emitting one on a path that can actually be reached makes the whole function’s behaviour undefined, not just that path’s.

Copying a struct and clearing a buffer are the two things a frontend would otherwise emit a byte loop for. Each takes three values and counts bytes:

esper_build_memcpy(b, dst, src, len);   // regions must not overlap
esper_build_memmove(b, dst, src, len);  // regions may overlap
esper_build_memset(b, dst, value, len); // writes value's low byte

bswap and bitrev have no emitter of their own because they are ordinary unary opcodes, and both reverse within the value’s own width:

EsperVal swapped  = esper_build_unary(b, ESP_OP_BSWAP, ESP_TYPE_I32, x);
EsperVal reversed = esper_build_unary(b, ESP_OP_BITREV, ESP_TYPE_I32, x);

Arithmetic that clamps, and arithmetic that reports

Plain add and mul wrap. A language that says otherwise needs one of two other answers, and each has its own emitter so that a wrong opcode is refused where it is written rather than where it is executed:

EsperVal clamped = esper_build_sat(b, ESP_OP_ADD_SAT_S, ESP_TYPE_I32, x, y);
EsperVal did_ovf = esper_build_overflow(b, ESP_OP_ADD_OVF_S, ESP_TYPE_I32, x, y);

esper_build_sat takes the four saturating opcodes, add.sat and sub.sat in a signed and an unsigned form, and yields the clamped result at the named width. esper_build_overflow takes the six checks, add, sub and mul in both signednesses, and yields a flag: the named type is the width the check is made at, and the result is an i32 that is one when the operation would have run past it. The wrapped value is a separate instruction, so a frontend wanting both emits the pair on the same operands.

What callers get wrong

Four things account for most of the mistakes, and none of them announce themselves in the type system.

Blocks must be bound in creation order

A block owns a contiguous run of the instruction stream. Binding out of order would ask the builder to interleave two runs, so it refuses:

EsperBlockId entry = esper_build_block(b, "entry");
EsperBlockId tail  = esper_build_block(b, "tail");

esper_build_bind(b, tail);      // error: build.block_order
esper_build_bind(b, entry);

That reports block 'tail' bound out of order; bind blocks in creation order, esper_builder_error_count becomes 1, and esper_build_end returns NULL.

Creating blocks up front and binding them in the same order is the whole discipline. It costs nothing, because creating a block does not commit to what goes in it, and a branch may name a block long before it is bound.

A block id is not a pointer

EsperBlockId is a uint32_t index, and deliberately so. The builder grows its block array as blocks are created, so an EsperBlock * handed out earlier would dangle after the next esper_build_block. An index stays valid for the whole build.

The consequence is that block ids are only meaningful to the builder that made them, and ESP_BLOCK_NONE rather than NULL is the failure value. The read-side EsperBlock * from esper_func_block is a different thing, obtained after the function is finished and its storage has stopped moving.

SSA ids are positional

An SSA id is not allocated. It is a position:

id = param_count + inst_index

Parameters occupy 0 .. param_count - 1, and every instruction then takes the next id in order. esper_func_value_count is param_count + inst_count, and that identity holds at every point in a build.

This is why an id is stable under an in-place pass and why nothing needs renumbering: the id is the position. It is also why an id from one function means nothing in another.

A void instruction still consumes an id

ret, jmp, jmp.if and store produce no value, and they take an id anyway, because the id is a position rather than a name.

EsperVal a = esper_build_ldi(b, ESP_TYPE_I32, 7);
esper_build_store(b, ESP_OP_STORE, ESP_TYPE_I32, esper_build_param(b, 0), a);
EsperVal c = esper_build_ldi(b, ESP_TYPE_I32, 9);

In a function with one parameter, a is %1 and c is %3, not %2. The store owns %2; esper_func_value_type(f, 2) answers ESP_TYPE_VOID, and esper_inst_id on the store returns 2. The printed form leaves the gap visible, because a void instruction has no result to print:

func $f(%0 ptr) -> i32 {
entry:
  %1 = ldi i32 7
  store i32 %0, %1
  %3 = ldi i32 9
  ret i32 %3
}

The same gaps appear in the loop above: jmp takes %2, which is why the first parameter is %3, and jmp.if takes %7, which is why the call is %8.

A frontend that keeps its own map from AST node to EsperVal never has to think about this, because it holds the ids the emitters returned. A frontend that tries to predict the next id has to count void instructions, and should not.

Positions, scopes, and names

A handful of calls exist purely so that what comes out the far end can be debugged. None affects the code that is generated.

esper_build_set_loc sets the source position later instructions carry, and that a diagnostic from this builder points at. A frontend calls it as it walks its own tree:

uint32_t file = esper_unit_add_source(unit, "hello.src");
esper_build_set_loc(b, file, 12, 5);

Positions are 1-based, and a line of zero means unknown, which is where a builder starts. esper_unit_add_source is idempotent on a path already registered, so a frontend calling it once per function still gets one entry.

esper_build_stamp_loc is for a reader that learns the position afterwards rather than before, a text parser meeting a trailing annotation being the case it exists for. It applies to the instruction just emitted, and its is_explicit argument says whether the position is one the source wrote down or one worth keeping only for diagnostics. esper_func_set_loc records where a function was declared, which a declaration has whether or not it has a body.

esper_build_scope_open and esper_build_scope_close bracket a lexical scope, and esper_build_local records that an SSA value is the frontend’s variable of a given name. Without them a variable in the debugger is a numbered register. A scope closed when none is open, or left open at esper_build_end, reports build.scope.

The debug information a backend writes is only as good as what the frontend recorded here. See Embedding for how those diagnostics reach you.

Constants, globals, and multi-return

esper_build_ldi carries a 32-bit immediate inside the instruction. Anything wider goes in the unit’s constant table first:

uint32_t k = esper_unit_add_const_i64(unit, 1ull << 40);
EsperVal big = esper_build_ldc(b, ESP_TYPE_I64, k);

esper_unit_add_const_f32 and esper_unit_add_const_f64 are the float equivalents, and esper_unit_add_const_int, esper_unit_add_const_float and esper_unit_add_const_v128 cover the narrower types and the vector one. Any of them returns ESP_NO_CONST when the entry could not be made.

An aggregate constant needs an image rather than a value. esper_unit_add_const_agg takes one the caller has already laid out; esper_const_agg_begin computes the offsets instead, so the members are written in declaration order and the ABI stays out of the frontend:

EsperConstAgg *ca = esper_const_agg_begin(unit, esper_tref_agg(point));
esper_const_agg_int(ca, 3);
esper_const_agg_int(ca, 4);
uint32_t init = esper_const_agg_end(ca);

Globals are added with esper_unit_add_global, which names a constant-table entry as its initializer, or with esper_unit_add_global_extern for one the host supplies. They are read and written by index with esper_build_global_get and esper_build_global_set, and esper_unit_set_global_loc records where the frontend declared them.

Reading back goes the other way. esper_unit_const_type says which of esper_unit_const_int, esper_unit_const_float and esper_unit_const_bytes applies, and a global reports its type, mutability, extern-ness and initializer, so a backend of your own can emit what a parsed unit holds.

A call to a multi-return function is built with ESP_TYPE_TUPLE as its result type, and the components are projected out one at a time:

EsperVal pair = esper_build_call(b, ESP_TYPE_TUPLE, "divmod", args, 2);
EsperVal quot = esper_build_extract(b, ESP_TYPE_I32, pair, 0);
EsperVal rem  = esper_build_extract(b, ESP_TYPE_I32, pair, 1);

Naming a value before it exists

A block can be branched to before it is filled, because esper_build_block hands out its id first and only decides its instruction range later. A value has no such escape: its id is its position in the stream, so nothing can name it until the instruction defining it exists.

Most frontends never meet this. A value arriving from a path not yet emitted travels as a branch argument, which the branch supplies once that path is emitted. What is left is a name read before its definition is reached at all, which is what a text format permits and a tree walk does not. For that there is a stand-in:

EsperVal later = esper_build_forward(b);
EsperVal sum = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I32, x, later);
...
esper_build_forward_define(b, later, real);

The stand-in goes in as an operand anywhere a real value can, and every slot holding it is rewritten when esper_build_forward_define says what it stood for. esper_build_end refuses the function if one was never defined, rather than emitting a dangling operand. esper_val_is_forward tells the two apart.

Storage, syscalls, and vectors

The instructions with no arithmetic shape of their own each have an emitter:

EsperVal mem = esper_build_alloc(b, ESP_OP_HALLOC, size);
esper_build_free(b, mem);

EsperVal ret = esper_build_syscall(b, ESP_TYPE_I64, number, args, 3);

EsperVal v = esper_build_vsplat(b, scalar, ESP_LANE_I32X4);
EsperVal w = esper_build_vbinary(b, ESP_OP_VADD, v, v, ESP_LANE_I32X4);
EsperVal lane = esper_build_vext(b, ESP_TYPE_I32, w, ESP_LANE_I32X4, 0);

esper_build_alloc takes ESP_OP_ALLOC, ESP_OP_SALLOC or ESP_OP_HALLOC and yields a pointer. esper_build_syscall picks the opcode from the argument count, so a caller never chooses between the seven spellings. esper_build_vunary, esper_build_vload and esper_build_vstore cover the rest of the vector set.

Describing a memory access

A frontend lowering a field access knows more about it than the address does. EsperMemAccess carries the rest: what the access is aligned to, whether it is volatile, and which member of which aggregate it reaches.

EsperMemAccess access = {0};
access.align = 4;
access.type_index = point;
access.member_index = 1;

EsperVal y = esper_build_load_at(b, ESP_OP_LOAD, ESP_TYPE_I32, addr, &access);

None of it changes what is computed, and all of it changes what a backend and a debugger can say. Zero-initialise the struct and fill in only what is known; type_index of ESP_NO_TYPE means the access is not attributed to a member. With a zeroed description these are esper_build_load and esper_build_store.

Types that name each other

esper_unit_add_struct and esper_unit_add_array declare a complete type in one call, which is what a frontend whose types are already ordered wants. Two types that name each other cannot both be complete when the first is declared, so the index has to exist before the members do:

uint32_t node = esper_unit_declare_type(unit, "Node", ESP_AGG_STRUCT);
uint32_t list = esper_unit_declare_type(unit, "List", ESP_AGG_STRUCT);

esper_unit_type_add_member(unit, node, "next", esper_tref_agg(node));
esper_unit_type_add_member(unit, list, "head", esper_tref_agg(node));
esper_type_layout(unit);

Nothing is laid out until esper_type_layout runs, which is the price of filling a type in over several calls. esper_unit_type_set_element says what an array or an alias is made of, and the esper_type_* queries read the table back: its kind, its members’ names and types, an array’s length.

After the build

A finished unit is a finished unit, whatever produced it. The optimizer, the backends and the interpreter cannot tell a built unit from a parsed one, so everything in Embedding applies unchanged:

EsperConfig cfg = {0};
cfg.opt_const_fold = true;
cfg.opt_dce = true;
esper_optimize(unit, &cfg);

cfg.backend = ESP_BACKEND_C;
esper_codegen(unit, &cfg, stdout);

Note that cfg.backend takes an EsperBackend, and cfg.target an EsperTarget. The two are separate on purpose: the backend is fixed when Esper is built, and the target is chosen per invocation, which is what lets one binary cross-compile. Build configuration covers which backends a given build contains.