comptime VM: host wiring, full corpus parity, build flag, Phase 3 seed

Phase 1.final of the flat-memory comptime VM — wire the host through it,
reach corpus parity, and gate it behind a build flag — plus the first
Phase 3 (compiler-API) step. Default OFF; legacy interpreter unchanged.

Host wiring + hardening:
- Machine accessors return error.OutOfBounds (no debug panic) on bad
  addresses; Frame.get/set bounds-check and bail (no panic) on a malformed
  operand ref (e.g. a ret Ref.none from an unresolved name).
- tryEval routed at both comptime call sites in emit_llvm — the const-init
  fold and the #run side-effect path — with per-eval legacy fallback;
  yields .void_val for void/noreturn entries. Both sites sx_trace_clear()
  before the legacy fallback so a partial VM run that pushed trace frames
  doesn't double-push on re-run.

VM coverage (all corpus const-inits except the inline-asm global):
- Implicit context materialized from the __sx_default_context global; the
  full allocator protocol runs on the VM (context.allocator.alloc ->
  call_indirect -> CAllocator thunk -> libc_malloc -> native flat malloc).
- Native libc memory builtins (malloc/calloc/free/memcpy/memmove/memset)
  on flat memory; f32 stored/loaded as the 4-byte single; signed sub-64-bit
  loads sign-extended; global_get (lazy + memoized); func_ref/call_indirect
  (func-ref encoded fid+1, 0 reserved for null); string/slice fat-pointer
  field access; is_comptime; the failable/error cluster (error_set tuples,
  trace_frame + native sx_trace_push/clear -> raise/catch/or + return traces).

Build flag + Phase 3 seed:
- -Dcomptime-flat (build_opts module) OR SX_COMPTIME_FLAT env enables the VM;
  zig build test -Dcomptime-flat runs the full corpus on the VM (688/0).
- intern/text_of serviced natively on flat memory via Vm.callCompilerFn
  (compiler_welded boundary) — the seed the rest of the compiler-API grows on.

Parity 688/688 gate ON and OFF. Unit tests added throughout. The
lowering-time #insert wiring was explored and reverted (lowering-time IR can
be malformed; full malformed-IR hardening is a prerequisite, deferred).
This commit is contained in:
agra
2026-06-18 08:27:58 +03:00
parent b8f3d6fd78
commit 0367d96d9b
7 changed files with 1142 additions and 108 deletions

View File

@@ -35,6 +35,15 @@ const Function = inst_mod.Function;
const Module = mod_mod.Module;
const OpTag = std.meta.Tag(inst_mod.Op);
const TypeId = types.TypeId;
const FuncId = inst_mod.FuncId;
// The error return-trace buffer (sx_trace.c, linked into the compiler) — the same
// one emit_llvm reads after a `#run` to render the comptime escape trace. A
// comptime failable that raises emits `sx_trace_push(trace_frame())` as it unwinds;
// the VM services those calls natively so the trace populates identically to legacy.
extern fn sx_trace_push(frame: u64) void;
extern fn sx_trace_clear() void;
const Span = inst_mod.Span;
/// A byte offset into the machine's flat memory. `null_addr` (0) is reserved as a
/// never-allocated sentinel, so a zeroed register reads as null rather than a
@@ -90,36 +99,40 @@ pub const Machine = struct {
}
/// Read a `size`-byte (1/2/4/8) little-endian scalar at `addr` into a register
/// word (zero-extended). Bounds- and null-checked.
pub fn readWord(self: *const Machine, addr: Addr, size: usize) Reg {
const a: usize = @intCast(addr);
std.debug.assert(addr != null_addr);
std.debug.assert(a + size <= self.mem.items.len);
std.debug.assert(size <= 8);
/// word (zero-extended). Bounds- and null-checked: a null / out-of-range /
/// oversized access returns `error.OutOfBounds` (NOT a debug panic) so a
/// malformed comptime run BAILS to the legacy fallback instead of crashing the
/// compiler. This is the safety contract `tryEval` relies on for arbitrary funcs.
pub fn readWord(self: *const Machine, addr: Addr, size: usize) error{OutOfBounds}!Reg {
if (addr == null_addr or size > 8) return error.OutOfBounds;
const a: usize = std.math.cast(usize, addr) orelse return error.OutOfBounds;
if (a >= self.mem.items.len or size > self.mem.items.len - a) return error.OutOfBounds;
var buf: [8]u8 = @splat(0);
@memcpy(buf[0..size], self.mem.items[a .. a + size]);
return std.mem.readInt(u64, &buf, .little);
}
/// Write the low `size` bytes (1/2/4/8) of register word `val` little-endian
/// at `addr`. Bounds- and null-checked.
pub fn writeWord(self: *Machine, addr: Addr, size: usize, val: Reg) void {
const a: usize = @intCast(addr);
std.debug.assert(addr != null_addr);
std.debug.assert(a + size <= self.mem.items.len);
std.debug.assert(size <= 8);
/// at `addr`. Bounds- and null-checked → `error.OutOfBounds` (not a panic).
pub fn writeWord(self: *Machine, addr: Addr, size: usize, val: Reg) error{OutOfBounds}!void {
if (addr == null_addr or size > 8) return error.OutOfBounds;
const a: usize = std.math.cast(usize, addr) orelse return error.OutOfBounds;
if (a >= self.mem.items.len or size > self.mem.items.len - a) return error.OutOfBounds;
var buf: [8]u8 = undefined;
std.mem.writeInt(u64, &buf, val, .little);
@memcpy(self.mem.items[a .. a + size], buf[0..size]);
}
/// A mutable byte view of `len` bytes at `addr` (for aggregate copies / slice
/// payloads). Bounds- and null-checked. The slice is invalidated by any
/// subsequent `allocBytes` that grows the backing — re-fetch after allocating.
pub fn bytes(self: *Machine, addr: Addr, len: usize) []u8 {
const a: usize = @intCast(addr);
std.debug.assert(addr != null_addr);
std.debug.assert(a + len <= self.mem.items.len);
/// payloads). Bounds- and null-checked → `error.OutOfBounds` (not a panic). A
/// zero-length view is always valid (no memory is touched). The slice is
/// invalidated by any subsequent `allocBytes` that grows the backing — re-fetch
/// after allocating.
pub fn bytes(self: *Machine, addr: Addr, len: usize) error{OutOfBounds}![]u8 {
if (len == 0) return self.mem.items[0..0];
if (addr == null_addr) return error.OutOfBounds;
const a: usize = std.math.cast(usize, addr) orelse return error.OutOfBounds;
if (a >= self.mem.items.len or len > self.mem.items.len - a) return error.OutOfBounds;
return self.mem.items[a .. a + len];
}
};
@@ -133,6 +146,11 @@ pub const Machine = struct {
pub const Frame = struct {
regs: []Reg,
gpa: std.mem.Allocator,
/// Set when `get`/`set` is handed an out-of-range Ref index — a malformed IR
/// (e.g. a `ret Ref.none` left by an unresolved name during LOWERING-time
/// comptime eval). The `run` loop checks it after each instruction and bails
/// (→ legacy fallback), so the VM never panics on imperfect IR.
bad_ref: bool = false,
pub fn init(gpa: std.mem.Allocator, num_regs: usize) Frame {
const regs = gpa.alloc(Reg, num_regs) catch @panic("comptime VM: out of memory (frame regs)");
@@ -144,36 +162,68 @@ pub const Frame = struct {
self.gpa.free(self.regs);
}
pub fn get(self: *const Frame, ref_index: usize) Reg {
pub fn get(self: *Frame, ref_index: usize) Reg {
if (ref_index >= self.regs.len) {
self.bad_ref = true;
return 0;
}
return self.regs[ref_index];
}
pub fn set(self: *Frame, ref_index: usize, word: Reg) void {
if (ref_index >= self.regs.len) {
self.bad_ref = true;
return;
}
self.regs[ref_index] = word;
}
};
/// Why the most recent `tryEval` returned `null` (bailed to the legacy
/// interpreter) — the bail `detail` (op name / one-line reason), or a fixed string
/// for the structural skips. Mirrors the legacy interp's `last_bail_detail`; the
/// host reads it under a coverage-trace gate to learn what to port next. Cleared at
/// the top of every `tryEval`; meaningful only when `tryEval` returned `null`.
pub var last_bail_reason: ?[]const u8 = null;
/// Wiring entry point: try to evaluate comptime function `func_id` entirely on the
/// flat-memory VM and return its result as a legacy `Value`, or `null` if the VM
/// can't handle it (unsupported op, no body, or any bail) — the caller then falls
/// back to the legacy interpreter. The result is deep-copied into `gpa`, so it
/// outlives the VM's flat memory (freed here on return).
///
/// SAFETY NOTE (host wiring prerequisite): the VM's memory accessors currently
/// `assert` on a null/out-of-bounds address (a debug panic), so this is only safe
/// for functions whose every access is well-formed. Before routing ARBITRARY host
/// comptime functions through here, harden `Machine.readWord`/`writeWord`/`bytes`
/// to return `error.OutOfBounds` instead of asserting — then a malformed run bails
/// (→ null → legacy fallback) rather than crashing the compiler.
/// Safe for ARBITRARY host comptime functions: the `Machine` accessors are
/// hardened to return `error.OutOfBounds` (not a debug panic) on a null/out-of-
/// range/oversized access, so a malformed run bails to `null` (→ legacy fallback)
/// rather than crashing the compiler. On a bail, `last_bail_reason` names the cause.
pub fn tryEval(gpa: std.mem.Allocator, module: *const Module, func_id: inst_mod.FuncId) ?Value {
last_bail_reason = null;
const func = module.getFunction(func_id);
if (func.is_extern or func.blocks.items.len == 0) return null;
if (func.is_extern or func.blocks.items.len == 0) {
last_bail_reason = "extern / no body";
return null;
}
var vm = Vm.init(gpa);
defer vm.deinit();
vm.table = &module.types;
vm.module = module;
const reg = vm.run(func, &.{}) catch return null;
return vm.regToValue(gpa, &module.types, reg, func.ret) catch null;
// `runEntry` materializes the implicit `*Context` (a comptime const-init /
// `#run` wrapper is nullary in user args, so the implicit ctx is its sole
// param) as a zeroed Context in flat memory and runs. The common const body
// never reads the ctx; one that uses the allocator hits unported
// `call_indirect` → bails → legacy. Gate-ON corpus parity validates this.
const reg = vm.runEntry(func_id) catch |err| {
last_bail_reason = vm.detail orelse @errorName(err);
return null;
};
// A void/noreturn entry (a `#run <expr>;` side-effect) produces no value —
// `regToValue` would bail on the void type, so yield `.void_val` directly.
if (func.ret == .void or func.ret == .noreturn) return .void_val;
return vm.regToValue(gpa, &module.types, reg, func.ret) catch |err| {
last_bail_reason = vm.detail orelse @errorName(err);
return null;
};
}
// ── Executor ────────────────────────────────────────────────────────────────
@@ -186,12 +236,27 @@ pub fn tryEval(gpa: std.mem.Allocator, module: *const Module, func_id: inst_mod.
// the declared width), float math is f64. Memory/aggregate/call ops are not ported
// yet — they bail loudly (`error.Unsupported` + `detail`), never silently.
pub const Error = error{ DivisionByZero, TypeError, Unsupported };
pub const Error = error{ DivisionByZero, TypeError, Unsupported, OutOfBounds };
fn isFloat(ty: TypeId) bool {
return ty == .f32 or ty == .f64;
}
/// A signed integer type narrower-or-equal to 64 bits — its loaded bytes must be
/// SIGN-extended into the register (the legacy `.int` model is i64).
fn isSignedInt(ty: TypeId) bool {
return switch (ty) {
.i8, .i16, .i32, .i64, .isize => true,
else => false,
};
}
/// Sign-extend a `sz`-byte (1/2/4) value (zero-extended in `raw`) to a 64-bit reg.
fn signExtendWord(raw: Reg, sz: usize) Reg {
const shift: u6 = @intCast((8 - sz) * 8);
return @bitCast((@as(i64, @bitCast(raw)) << shift) >> shift);
}
pub const Vm = struct {
machine: Machine,
gpa: std.mem.Allocator,
@@ -209,17 +274,133 @@ pub const Vm = struct {
/// tag name or a one-line explanation. Mirrors the legacy interp's
/// `last_bail_detail` so the host can surface a real message, not a bare error.
detail: ?[]const u8 = null,
/// Per-global memo of comptime-evaluated globals (the legacy interp's
/// `global_values`): `global_get` caches a global's Reg so a chain of globals
/// reading each other doesn't re-run inits (and so each runs at most once).
global_cache: std.AutoHashMap(u32, Reg),
/// The active call chain of `FuncId`s (mirrors the legacy interp's
/// `call_chain`). `trace_frame` packs the top of this stack into a return-trace
/// frame; pushed by `invoke`/`runEntry`, popped on return.
call_stack: std.ArrayList(FuncId) = .empty,
pub const max_depth: u32 = 512;
pub fn init(gpa: std.mem.Allocator) Vm {
return .{ .machine = Machine.init(gpa), .gpa = gpa };
return .{ .machine = Machine.init(gpa), .gpa = gpa, .global_cache = std.AutoHashMap(u32, Reg).init(gpa) };
}
pub fn deinit(self: *Vm) void {
self.global_cache.deinit();
self.call_stack.deinit(self.gpa);
self.machine.deinit();
}
/// Run a comptime ENTRY function (nullary in user args): materialize the
/// implicit `*Context` arg if the function declares one, then run. Shared by
/// `tryEval` (the host entry) and `evalGlobal` (a comptime global's init). The
/// materialized ctx is zeroed; a body that ignores it runs, one that uses the
/// allocator hits unported `call_indirect` and bails.
fn runEntry(self: *Vm, func_id: FuncId) Error!Reg {
const module = self.module orelse return self.failMsg("comptime VM: entry run needs a module");
const func = module.getFunction(func_id);
var argbuf: [1]Reg = undefined;
var args: []const Reg = &.{};
if (func.has_implicit_ctx) {
if (func.params.len != 1) return self.failMsg("comptime VM: has_implicit_ctx with non-ctx params");
argbuf[0] = try self.materializeDefaultContext(module);
args = argbuf[0..1];
}
self.call_stack.append(self.gpa, func_id) catch @panic("comptime VM: out of memory (call stack)");
defer _ = self.call_stack.pop();
return self.run(func, args);
}
/// Materialize the default `Context` in flat memory and return its address —
/// the VM analogue of the static `__sx_default_context` global / the legacy
/// `defaultContextValue`. The implicit-ctx param is an opaque `*void`, so the
/// real Context type AND its initializer (the nested `{ {null, alloc_fn,
/// dealloc_fn}, null }` constant carrying the CAllocator thunk func-refs) come
/// from the `__sx_default_context` global. Laying that constant into flat memory
/// gives a context whose `alloc_fn`/`dealloc_fn` are real func-refs, so a
/// comptime body that allocates via `context.allocator` dispatches through
/// `call_indirect` to the thunk to `CAllocator.alloc_bytes` to `libc_malloc` to
/// the VM's native `malloc` (flat memory) — all on the VM, no host heap. If no
/// `__sx_default_context` global exists, bail (legacy fallback).
fn materializeDefaultContext(self: *Vm, module: *const Module) Error!Addr {
const table = self.table orelse return self.failMsg("comptime VM: default context needs a type table");
for (module.globals.items) |*g| {
if (!std.mem.eql(u8, module.types.getString(g.name), "__sx_default_context")) continue;
const addr = self.machine.allocBytes(table.typeSizeBytes(g.ty), table.typeAlignBytes(g.ty)); // zeroed
if (g.init_val) |iv| try self.layoutConst(table, iv, g.ty, addr);
return addr;
}
return self.failMsg("comptime VM: no __sx_default_context global to materialize the implicit context");
}
/// Lay a static `ConstantValue` of type `ty` into flat memory at `addr` (the
/// destination is pre-zeroed). Scalars/func-refs write a word; a null/zero/undef
/// leaf stays zeroed; an aggregate recurses per field at the type's natural
/// offsets. Builds the default context from its global constant.
fn layoutConst(self: *Vm, table: *const types.TypeTable, cv: inst_mod.ConstantValue, ty: TypeId, addr: Addr) Error!void {
switch (cv) {
.int => |v| try self.writeField(table, addr, ty, @bitCast(v)),
.boolean => |b| try self.writeField(table, addr, ty, @intFromBool(b)),
.float => |v| try self.writeField(table, addr, ty, @bitCast(v)),
.func_ref => |fid| try self.writeField(table, addr, ty, funcRefWord(fid)),
.null_val, .zeroinit, .undef => {}, // destination already zeroed
.aggregate => |fields| {
if (ty.isBuiltin()) return self.failMsg("comptime VM: const aggregate at a builtin type");
switch (table.get(ty)) {
.@"struct" => |s| for (fields, 0..) |fv, i| {
if (i >= s.fields.len) break;
try self.layoutConst(table, fv, s.fields[i].ty, addr + fieldOffset(table, ty, @intCast(i)));
},
.tuple => |t| for (fields, 0..) |fv, i| {
if (i >= t.fields.len) break;
try self.layoutConst(table, fv, t.fields[i], addr + tupleFieldOffset(table, ty, @intCast(i)));
},
.array => |a| for (fields, 0..) |fv, i| {
try self.layoutConst(table, fv, a.element, addr + @as(Addr, @intCast(i)) * @as(Addr, @intCast(table.typeSizeBytes(a.element))));
},
else => return self.failMsg("comptime VM: const aggregate at an unsupported type"),
}
},
.string, .vtable => return self.failMsg("comptime VM: const string/vtable not supported in layoutConst yet"),
}
}
/// Evaluate comptime global `gid` to its Reg value — lazily running its
/// `comptime_func` (with implicit-ctx bootstrap), or reading a scalar static
/// `init_val` — memoized in `global_cache`. The legacy `getGlobal` analogue.
fn evalGlobal(self: *Vm, gid: inst_mod.GlobalId) Error!Reg {
const module = self.module orelse return self.failMsg("comptime VM: global_get needs a module");
const idx = gid.index();
if (self.global_cache.get(idx)) |r| return r;
if (idx >= module.globals.items.len) return self.failMsg("comptime VM: global_get index out of range");
const global = &module.globals.items[idx];
const r: Reg = if (global.comptime_func) |fid|
try self.runEntry(fid)
else if (global.init_val) |iv|
try self.constToReg(iv)
else
return self.failMsg("comptime VM: global_get of a global with no comptime_func / init_val");
self.global_cache.put(idx, r) catch @panic("comptime VM: out of memory (global cache)");
return r;
}
/// Convert a static `ConstantValue` (a global's `init_val`) to a Reg. Scalars
/// only for now (float regs hold f64 bits — storage narrows f32); aggregate /
/// string / vtable / func_ref bail loudly (add when a real global_get needs it).
fn constToReg(self: *Vm, cv: inst_mod.ConstantValue) Error!Reg {
return switch (cv) {
.int => |v| @bitCast(v),
.boolean => |b| @intFromBool(b),
.float => |v| @bitCast(v),
.null_val, .zeroinit, .undef => null_addr,
else => self.failMsg("comptime VM: global_get static init kind not yet supported (string/aggregate/vtable/func_ref)"),
};
}
/// Run `func` with scalar `args` (one `Reg` word each, in param order) and
/// return the scalar result word. `ret_void` / falling off a block with no
/// terminator yields 0. Aggregate args/results await the memory sub-step.
@@ -263,10 +444,15 @@ pub const Vm = struct {
const bp = ins.op.block_param;
if (bp.param_index < block_args.len)
frame.set(ref, frame.get(block_args[bp.param_index].index()));
if (frame.bad_ref) return self.badRef();
ref += 1;
continue;
}
switch (try self.exec(ins, &frame, ref_types)) {
const step = try self.exec(ins, &frame, ref_types);
// A malformed IR (an out-of-range / `Ref.none` operand from an
// unresolved name) flips `frame.bad_ref` instead of panicking — bail.
if (frame.bad_ref) return self.badRef();
switch (step) {
.value => |w| {
frame.set(ref, w);
ref += 1;
@@ -358,7 +544,13 @@ pub const Vm = struct {
.struct_get => |fa| {
const table = try self.requireTable();
const sty = aggType(table, fa, ref_types);
const fty = table.get(sty).@"struct".fields[fa.field_index].ty;
// For a real struct the field type comes from the table; for a
// string/slice fat-pointer base ({ptr,len}) the result type IS the
// field type (`ins.ty`).
const fty = if (!sty.isBuiltin() and table.get(sty) == .@"struct")
table.get(sty).@"struct".fields[fa.field_index].ty
else
ins.ty;
return .{ .value = try self.readField(table, frame.get(fa.base.index()) + fieldOffset(table, sty, fa.field_index), fty) };
},
.struct_gep => |fa| {
@@ -400,11 +592,11 @@ pub const Vm = struct {
.length => |u| {
const table = try self.requireTable();
const oty = ref_types[u.operand.index()];
if (oty == .string) return .{ .value = self.sliceLen(frame.get(u.operand.index())) };
if (oty == .string) return .{ .value = try self.sliceLen(frame.get(u.operand.index())) };
if (!oty.isBuiltin()) {
switch (table.get(oty)) {
.array => |a| return .{ .value = a.length },
.slice => return .{ .value = self.sliceLen(frame.get(u.operand.index())) },
.slice => return .{ .value = try self.sliceLen(frame.get(u.operand.index())) },
else => {},
}
}
@@ -417,14 +609,14 @@ pub const Vm = struct {
const table = try self.requireTable();
const text = table.getString(sid);
const data = self.machine.allocBytes(text.len + 1, 1); // +1: NUL (zero-init)
if (text.len > 0) @memcpy(self.machine.bytes(data, text.len), text);
return .{ .value = self.makeSlice(table, data, text.len) };
if (text.len > 0) @memcpy(try self.machine.bytes(data, text.len), text);
return .{ .value = try self.makeSlice(table, data, text.len) };
},
.data_ptr => |u| {
const table = try self.requireTable();
const oty = ref_types[u.operand.index()];
if (oty == .string or (!oty.isBuiltin() and table.get(oty) == .slice))
return .{ .value = self.sliceData(table, frame.get(u.operand.index())) };
return .{ .value = try self.sliceData(table, frame.get(u.operand.index())) };
self.detail = "comptime VM: .ptr (data_ptr) on a non-slice/string operand";
return error.Unsupported;
},
@@ -436,7 +628,7 @@ pub const Vm = struct {
self.detail = "comptime VM: array_to_slice on a non-array operand";
return error.Unsupported;
}
return .{ .value = self.makeSlice(table, frame.get(u.operand.index()), table.get(aty).array.length) };
return .{ .value = try self.makeSlice(table, frame.get(u.operand.index()), table.get(aty).array.length) };
},
.subslice => |s| {
const table = try self.requireTable();
@@ -447,13 +639,13 @@ pub const Vm = struct {
var elem: TypeId = .u8;
var data: Addr = base;
if (bty == .string) {
data = self.sliceData(table, base);
data = try self.sliceData(table, base);
} else if (!bty.isBuiltin()) {
switch (table.get(bty)) {
.array => |a| elem = a.element,
.slice => |sl| {
elem = sl.element;
data = self.sliceData(table, base);
data = try self.sliceData(table, base);
},
else => {
self.detail = "comptime VM: subslice on a non-array/slice/string base";
@@ -465,14 +657,14 @@ pub const Vm = struct {
return error.Unsupported;
}
const esz: u64 = @intCast(table.typeSizeBytes(elem));
return .{ .value = self.makeSlice(table, data +% lo *% esz, hi - lo) };
return .{ .value = try self.makeSlice(table, data +% lo *% esz, hi - lo) };
},
.str_eq, .str_ne => |b| {
const table = try self.requireTable();
const lb = frame.get(b.lhs.index());
const rb = frame.get(b.rhs.index());
const ls = self.machine.bytes(self.sliceData(table, lb), @intCast(self.sliceLen(lb)));
const rs = self.machine.bytes(self.sliceData(table, rb), @intCast(self.sliceLen(rb)));
const ls = try self.machine.bytes(try self.sliceData(table, lb), @intCast(try self.sliceLen(lb)));
const rs = try self.machine.bytes(try self.sliceData(table, rb), @intCast(try self.sliceLen(rb)));
const eq = std.mem.eql(u8, ls, rs);
return .{ .value = @intFromBool(if (std.meta.activeTag(ins.op) == .str_eq) eq else !eq) };
},
@@ -485,14 +677,14 @@ pub const Vm = struct {
if (optChildIsPtr(table, child)) return .{ .value = val }; // pointer optional: the pointer
const addr = self.machine.allocBytes(table.typeSizeBytes(ins.ty), table.typeAlignBytes(ins.ty));
try self.writeField(table, addr, child, val); // payload @ 0
self.machine.writeWord(addr + table.typeSizeBytes(child), 1, 1); // has_value flag = 1
try self.machine.writeWord(addr + table.typeSizeBytes(child), 1, 1); // has_value flag = 1
return .{ .value = addr };
},
.optional_unwrap => |u| {
const table = try self.requireTable();
const opt_ty = ref_types[u.operand.index()];
const v = frame.get(u.operand.index());
if (!self.optHas(table, opt_ty, v)) {
if (!try self.optHas(table, opt_ty, v)) {
self.detail = "comptime VM: unwrap of a null optional";
return error.TypeError;
}
@@ -502,13 +694,13 @@ pub const Vm = struct {
},
.optional_has_value => |u| {
const table = try self.requireTable();
return .{ .value = @intFromBool(self.optHas(table, ref_types[u.operand.index()], frame.get(u.operand.index()))) };
return .{ .value = @intFromBool(try self.optHas(table, ref_types[u.operand.index()], frame.get(u.operand.index()))) };
},
.optional_coalesce => |b| {
const table = try self.requireTable();
const opt_ty = ref_types[b.lhs.index()];
const v = frame.get(b.lhs.index());
if (self.optHas(table, opt_ty, v)) {
if (try self.optHas(table, opt_ty, v)) {
const child = table.get(opt_ty).optional.child;
if (optChildIsPtr(table, child)) return .{ .value = v };
return .{ .value = try self.readField(table, v, child) };
@@ -534,25 +726,41 @@ pub const Vm = struct {
return error.Unsupported;
},
// `is_comptime()` — always true on the comptime VM (folds to false in
// compiled code). Mirrors the legacy interp's `.is_comptime => true`.
.is_comptime => return .{ .value = @as(Reg, 1) },
// A comptime return-trace frame: pack `(func_id << 32 | span.start)`
// from the top of the call chain (mirrors the legacy interp). The
// failable-propagation lowering feeds this to `sx_trace_push`.
.trace_frame => {
const fid: u64 = if (self.call_stack.items.len > 0) self.call_stack.items[self.call_stack.items.len - 1].index() else 0;
return .{ .value = (fid << 32) | @as(u64, ins.span.start) };
},
// ── Calls ───────────────────────────────────────────
.call => |c| {
const module = self.module orelse {
self.detail = "comptime VM: call needs a module (not provided)";
// Direct call: resolve the static callee `FuncId` and dispatch.
.call => |c| return .{ .value = try self.invoke(c.callee, c.args, frame) },
// Indirect call: the callee is a `func_ref` value (its `FuncId.index()`
// as a word) in a register — e.g. an allocator protocol's `alloc_fn`.
// A null (0) function pointer can't be dispatched → bail.
.call_indirect => |ci| {
const w = frame.get(ci.callee.index());
const fid = funcRefToId(w) orelse {
self.detail = "comptime VM: call_indirect through a null function pointer";
return error.Unsupported;
};
const callee = module.getFunction(c.callee);
if (callee.is_extern or callee.blocks.items.len == 0) {
self.detail = "comptime VM: call to an extern/builtin function not yet ported";
return error.Unsupported;
}
// Marshal arg Refs → Reg words (aggregates pass as their Addr — the
// callee shares this machine's flat memory, so no copy is needed).
const argbuf = self.gpa.alloc(Reg, c.args.len) catch @panic("comptime VM: out of memory (call args)");
defer self.gpa.free(argbuf);
for (c.args, 0..) |a, i| argbuf[i] = frame.get(a.index());
return .{ .value = try self.run(callee, argbuf) };
return .{ .value = try self.invoke(fid, ci.args, frame) };
},
// ── Globals / function values ───────────────────────
// Read another comptime global by lazily evaluating its init (its
// `comptime_func` run on this same VM, or a scalar static value),
// memoized. Mirrors the legacy interp's `getGlobal`.
.global_get => |gid| return .{ .value = try self.evalGlobal(gid) },
// A function value is its encoded func-ref word (see `funcRefWord`).
.func_ref => |fid| return .{ .value = funcRefWord(fid) },
// ── Pointers ────────────────────────────────────────
// `@x` — pass through: an aggregate value already IS its address, and a
// pointer value is already an address (mirrors the legacy interp).
@@ -663,6 +871,149 @@ pub const Vm = struct {
return error.Unsupported;
}
fn badRef(self: *Vm) error{Unsupported} {
self.detail = "comptime VM: malformed IR — operand ref out of range (unresolved name?)";
return error.Unsupported;
}
/// Dispatch a call to function `fid` with `args` (Refs in the current frame),
/// shared by `call` (static callee) and `call_indirect` (func-ref callee). An
/// extern/bodyless callee routes to the native libc memory builtins (else
/// bails); a normal callee runs on the VM. Aggregate args pass as their Addr
/// over the shared flat memory (no copy).
fn invoke(self: *Vm, fid: inst_mod.FuncId, args: []const Ref, frame: *Frame) Error!Reg {
const module = self.module orelse return self.failMsg("comptime VM: call needs a module (not provided)");
if (fid.index() >= module.functions.items.len) return self.failMsg("comptime VM: call to an out-of-range function id");
const callee = module.getFunction(fid);
if (callee.is_extern or callee.blocks.items.len == 0) {
const name = module.types.getString(callee.name);
// A curated set of libc MEMORY builtins is modeled natively on flat
// memory (sandboxed, target-aware) — comptime malloc/free/memcpy/…
// never reach the host heap or dlsym.
if (try self.callMemBuiltin(name, args, frame)) |r| return r;
// A welded `compiler`-library function (`abi(.zig) extern compiler`):
// the comptime compiler-API, serviced natively on flat memory (Phase 3
// seed). The `compiler_welded` flag is the safety boundary.
if (callee.compiler_welded) {
if (try self.callCompilerFn(name, args, frame)) |r| return r;
}
// Any other extern bails → the legacy interpreter's dlsym path.
self.detail = "comptime VM: call to an extern/builtin function not yet ported";
return error.Unsupported;
}
const argbuf = self.gpa.alloc(Reg, args.len) catch @panic("comptime VM: out of memory (call args)");
defer self.gpa.free(argbuf);
for (args, 0..) |a, i| argbuf[i] = frame.get(a.index());
self.call_stack.append(self.gpa, fid) catch @panic("comptime VM: out of memory (call stack)");
defer _ = self.call_stack.pop();
return self.run(callee, argbuf);
}
/// Largest single comptime allocation the VM will service natively. A bogus /
/// pathological comptime `malloc` above this bails to the legacy path (which
/// calls real libc) rather than OOM-panicking the compiler via `allocBytes`.
const max_builtin_alloc: usize = 1 << 28; // 256 MiB
/// Read call arg `i` as a non-negative byte count (libc size/length arg).
fn argLen(self: *Vm, args: []const Ref, frame: *Frame, i: usize) Error!usize {
const w: i64 = @bitCast(frame.get(args[i].index()));
return std.math.cast(usize, w) orelse self.failMsg("comptime mem builtin: negative/oversized size arg");
}
/// Model a curated set of libc MEMORY builtins directly on flat memory, so a
/// comptime `malloc`/`free`/`memcpy`/… stays sandboxed (no host heap, no
/// dlsym) and target-aware. Returns the result word, or `null` if `name` is
/// not one of them (the caller then bails to the legacy interpreter). libc
/// `malloc` returns 16-byte-aligned storage; we mirror that. The COMPUTED
/// result is byte-identical to the legacy path (which calls real libc) — only
/// the backing memory differs (flat vs host heap), which the result can't see.
fn callMemBuiltin(self: *Vm, name: []const u8, args: []const Ref, frame: *Frame) Error!?Reg {
// Error return-trace runtime (sx_trace.c, linked into the compiler). A
// comptime failable that raises emits `sx_trace_push(trace_frame())` as it
// unwinds; service it natively so the trace buffer the host reads is
// populated identically to the legacy interp's dlsym path.
if (std.mem.eql(u8, name, "sx_trace_push")) {
if (args.len >= 1) sx_trace_push(frame.get(args[0].index()));
return @as(Reg, 0);
}
if (std.mem.eql(u8, name, "sx_trace_clear")) {
sx_trace_clear();
return @as(Reg, 0);
}
if (std.mem.eql(u8, name, "malloc")) {
if (args.len < 1) return self.failMsg("comptime malloc: missing size arg");
const size = try self.argLen(args, frame, 0);
if (size > max_builtin_alloc) return self.failMsg("comptime malloc: size exceeds the VM cap");
return self.machine.allocBytes(size, 16);
}
if (std.mem.eql(u8, name, "calloc")) {
if (args.len < 2) return self.failMsg("comptime calloc: missing args");
const n = try self.argLen(args, frame, 0);
const sz = try self.argLen(args, frame, 1);
const total = std.math.mul(usize, n, sz) catch return self.failMsg("comptime calloc: size overflow");
if (total > max_builtin_alloc) return self.failMsg("comptime calloc: size exceeds the VM cap");
return self.machine.allocBytes(total, 16); // allocBytes zero-inits
}
if (std.mem.eql(u8, name, "free")) {
// No per-object free: comptime allocations live to `Vm.deinit`.
return @as(Reg, 0);
}
if (std.mem.eql(u8, name, "memcpy") or std.mem.eql(u8, name, "memmove")) {
if (args.len < 3) return self.failMsg("comptime memcpy: missing args");
const dst = frame.get(args[0].index());
const src = frame.get(args[1].index());
const n = try self.argLen(args, frame, 2);
if (n > 0) {
const d = try self.machine.bytes(dst, n);
const s = try self.machine.bytes(src, n);
// Overlap-safe (memmove semantics; correct for memcpy's too).
if (dst < src) std.mem.copyForwards(u8, d, s) else std.mem.copyBackwards(u8, d, s);
}
return dst; // libc returns dst
}
if (std.mem.eql(u8, name, "memset")) {
if (args.len < 3) return self.failMsg("comptime memset: missing args");
const dst = frame.get(args[0].index());
const byte: u8 = @truncate(frame.get(args[1].index()));
const n = try self.argLen(args, frame, 2);
if (n > 0) @memset(try self.machine.bytes(dst, n), byte);
return dst; // libc returns dst
}
return null; // not a modeled builtin → caller bails to legacy
}
/// Service a welded `compiler`-library function natively on flat memory — the
/// comptime compiler-API (Phase 3 of `PLAN-COMPILER-VM.md`). Returns the result
/// word, or `null` for an unknown name (caller bails → legacy). Mirrors the
/// legacy `compiler_lib` handlers, but reads/writes flat memory directly instead
/// of marshaling `Value`s. The seed pair is the string-pool round-trip:
/// `intern(s: string) -> StringId` and `text_of(id: StringId) -> string`.
fn callCompilerFn(self: *Vm, name: []const u8, args: []const Ref, frame: *Frame) Error!?Reg {
const table = try self.requireTable();
if (std.mem.eql(u8, name, "intern")) {
if (args.len != 1) return self.failMsg("comptime intern: expected one string arg");
const s = frame.get(args[0].index()); // string fat-pointer Addr
const text = try self.machine.bytes(try self.sliceData(table, s), @intCast(try self.sliceLen(s)));
// The string pool is genuinely mutable; the VM holds the table `const`
// (it never mutates TYPE layout — interning a string is pool-only, so it
// can't invalidate the cached type sizes the VM relies on). Same access
// the legacy `compiler_lib.mintTable` uses.
const id = @constCast(table).internString(text);
return @as(Reg, @intFromEnum(id));
}
if (std.mem.eql(u8, name, "text_of")) {
if (args.len != 1) return self.failMsg("comptime text_of: expected one StringId arg");
const raw = frame.get(args[0].index());
if (raw > std.math.maxInt(u32)) return self.failMsg("comptime text_of: StringId out of range");
const id: types.StringId = @enumFromInt(@as(u32, @intCast(raw)));
const text = table.getString(id);
const data = self.machine.allocBytes(text.len + 1, 1); // +1: NUL (zero-init)
if (text.len > 0) @memcpy(try self.machine.bytes(data, text.len), text);
return try self.makeSlice(table, data, text.len);
}
return null; // not a known compiler function → caller bails to legacy
}
// ── Reg ↔ Value bridge (legacy-interop boundary) ────────────────────────
//
// The wiring step routes a comptime eval through the VM, falling back to the
@@ -692,7 +1043,7 @@ pub const Vm = struct {
else => return self.failMsg("value→reg: expected a string literal value"),
};
const data = self.machine.allocBytes(text.len + 1, 1);
if (text.len > 0) @memcpy(self.machine.bytes(data, text.len), text);
if (text.len > 0) @memcpy(try self.machine.bytes(data, text.len), text);
return self.makeSlice(table, data, text.len);
}
const info = table.get(ty);
@@ -722,11 +1073,18 @@ pub const Vm = struct {
.word => {
if (isFloat(ty)) return .{ .float = @bitCast(reg) };
if (ty == .bool) return .{ .boolean = reg != 0 };
// A function-typed word is an encoded func-ref; map it back to
// `.func_ref` (or `.null_val` for the null word) so the host
// serializes it identically to the legacy (e.g. the comptime-global
// func-ref rejection diagnostic).
if (isFuncRefType(table, ty)) {
return if (funcRefToId(reg)) |fid| .{ .func_ref = fid } else .null_val;
}
return .{ .int = @bitCast(reg) };
},
.aggregate => {
if (ty == .string) {
const src = self.machine.bytes(self.sliceData(table, reg), @intCast(self.sliceLen(reg)));
const src = try self.machine.bytes(try self.sliceData(table, reg), @intCast(try self.sliceLen(reg)));
return .{ .string = alloc.dupe(u8, src) catch return self.failMsg("reg→value: out of memory (string)") };
}
const info = table.get(ty);
@@ -738,6 +1096,17 @@ pub const Vm = struct {
}
return .{ .aggregate = out };
}
if (info == .tuple) {
// A failable `(value…, error_tag)` is a tuple; the host's
// `checkComptimeFailable` reads the last field as the tag.
const elems = info.tuple.fields;
const out = alloc.alloc(Value, elems.len) catch return self.failMsg("reg→value: out of memory (tuple)");
for (elems, 0..) |ety, i| {
const fr = try self.readField(table, reg + tupleFieldOffset(table, ty, @intCast(i)), ety);
out[i] = try self.regToValue(alloc, table, fr, ety);
}
return .{ .aggregate = out };
}
return self.failMsg("reg→value: aggregate shape not bridged yet");
},
.unsupported => return self.failMsg("reg→value: unsupported type"),
@@ -759,6 +1128,7 @@ pub const Vm = struct {
return switch (table.get(ty)) {
.pointer, .many_pointer, .function => .word,
.@"enum" => .word, // payloadless enum: i64 (or its backing) — a word
.error_set => .word, // the error channel is a u32 tag id — a word
.@"struct", .array, .tuple, .slice => .aggregate,
// `?T`: a pointer child is null-as-0 (word); else `{T, i1}` by-address.
.optional => |o| if (optChildIsPtr(table, o.child)) .word else .aggregate,
@@ -766,6 +1136,29 @@ pub const Vm = struct {
};
}
/// A function value (func-ref) is encoded in a register as `FuncId.index() + 1`
/// so that 0 is reserved for the NULL function pointer (a `FuncId` of 0 is a
/// real function and must stay distinguishable from null). `funcRefWord` encodes;
/// `funcRefToId` decodes (returns null for the 0/null word).
fn funcRefWord(fid: inst_mod.FuncId) Reg {
return @as(Reg, fid.index()) + 1;
}
fn funcRefToId(word: Reg) ?inst_mod.FuncId {
if (word == null_addr) return null;
return inst_mod.FuncId.fromIndex(@intCast(word - 1));
}
/// Is `ty` a function value type — a function type directly, or a pointer to
/// one? Such a word holds an encoded func-ref (see `funcRefWord`), not a raw int.
fn isFuncRefType(table: *const types.TypeTable, ty: TypeId) bool {
if (ty.isBuiltin()) return false;
return switch (table.get(ty)) {
.function => true,
.pointer => |p| !p.pointee.isBuiltin() and table.get(p.pointee) == .function,
else => false,
};
}
/// A `?T` whose child is a pointer/many-pointer/function is represented as a
/// bare pointer (null == 0), not a `{T, i1}` aggregate — mirrors `typeSizeBytes`.
fn optChildIsPtr(table: *const types.TypeTable, child: TypeId) bool {
@@ -779,18 +1172,33 @@ pub const Vm = struct {
/// Does an optional value `v` of type `opt_ty` hold a value? A pointer optional
/// is present iff non-null; a `{T,i1}` optional is none when `v` is `null_addr`
/// (the `const_null` form) else its flag byte (at offset `sizeof(child)`) is set.
fn optHas(self: *Vm, table: *const types.TypeTable, opt_ty: TypeId, v: Reg) bool {
fn optHas(self: *Vm, table: *const types.TypeTable, opt_ty: TypeId, v: Reg) Error!bool {
const child = table.get(opt_ty).optional.child;
if (optChildIsPtr(table, child)) return v != null_addr;
if (v == null_addr) return false;
return self.machine.readWord(v + table.typeSizeBytes(child), 1) != 0;
return (try self.machine.readWord(v + table.typeSizeBytes(child), 1)) != 0;
}
/// Read a value of type `ty` from flat address `addr`: a scalar reads its
/// bytes; an aggregate value IS its address (it lives inline at `addr`).
/// `f32` is special: float REGISTERS hold f64 bits (like the legacy interp's
/// `.float`), but memory holds the 4-byte IEEE-754 single — so read 4 bytes as
/// `f32` and widen to the f64 register form. A SIGNED sub-64-bit integer
/// (`i8`/`i16`/`i32`/`isize`) is SIGN-extended into the 64-bit register — the
/// legacy `.int` model is i64, so a stored-and-reloaded negative value must
/// stay negative (else e.g. `i32 -1` reloads as `0xFFFFFFFF` and `< 0` is false).
fn readField(self: *Vm, table: *const types.TypeTable, addr: Addr, ty: TypeId) Error!Reg {
if (ty == .f32) {
const bits: u32 = @truncate(try self.machine.readWord(addr, 4));
const f: f32 = @bitCast(bits);
return @bitCast(@as(f64, f));
}
return switch (kindOf(table, ty)) {
.word => self.machine.readWord(addr, table.typeSizeBytes(ty)),
.word => {
const sz = table.typeSizeBytes(ty);
const raw = try self.machine.readWord(addr, sz);
return if (isSignedInt(ty) and sz < 8) signExtendWord(raw, sz) else raw;
},
.aggregate => addr,
.unsupported => {
self.detail = "comptime VM: value type not yet supported on flat memory (slice/optional/enum/array/etc.)";
@@ -801,13 +1209,28 @@ pub const Vm = struct {
/// Write register word `val` (of type `ty`) to flat address `addr`: a scalar
/// writes its bytes; an aggregate copies `sizeof(ty)` bytes from `val` (its
/// source address) into `addr`.
/// source address) into `addr`. A `null_addr` aggregate source is the
/// null/none sentinel (a non-pointer `?T` set to `null`, an empty slice/string,
/// …): there is no source object to copy, so the destination is ZEROED — the
/// all-zero representation IS none / `{ptr:0,len:0}` (flag byte 0 → not present).
fn writeField(self: *Vm, table: *const types.TypeTable, addr: Addr, ty: TypeId, val: Reg) Error!void {
// `f32`: the register holds f64 bits (see `readField`); narrow to a 4-byte
// IEEE-754 single for storage — mirrors the legacy interp's `@floatCast`.
if (ty == .f32) {
const f: f32 = @floatCast(@as(f64, @bitCast(val)));
const bits: u32 = @bitCast(f);
return self.machine.writeWord(addr, 4, bits);
}
switch (kindOf(table, ty)) {
.word => self.machine.writeWord(addr, table.typeSizeBytes(ty), val),
.word => try self.machine.writeWord(addr, table.typeSizeBytes(ty), val),
.aggregate => {
const n = table.typeSizeBytes(ty);
if (n > 0) @memcpy(self.machine.bytes(addr, n), self.machine.bytes(val, n));
if (n == 0) return;
if (val == null_addr) {
@memset(try self.machine.bytes(addr, n), 0);
} else {
@memcpy(try self.machine.bytes(addr, n), try self.machine.bytes(val, n));
}
},
.unsupported => {
self.detail = "comptime VM: value type not yet supported on flat memory (slice/optional/enum/array/etc.)";
@@ -819,8 +1242,11 @@ pub const Vm = struct {
/// The byte offset of struct field `idx`, computed the same way
/// `TypeTable.typeSizeBytes` lays a struct out (each field aligned to its own
/// alignment, in declaration order) — so init/get/gep agree, and the layout
/// matches the table's size computation.
/// matches the table's size computation. A string/slice is a `{ptr@0, len@8}`
/// fat pointer (the `makeSlice` layout), accessed by field 0 (ptr) / 1 (len).
fn fieldOffset(table: *const types.TypeTable, sty: TypeId, idx: u32) Addr {
if (sty == .string or (!sty.isBuiltin() and table.get(sty) == .slice))
return if (idx == 0) 0 else 8;
const fields = table.get(sty).@"struct".fields;
var off: usize = 0;
for (fields, 0..) |f, i| {
@@ -874,7 +1300,7 @@ pub const Vm = struct {
/// base (`slice` / `string`).
fn elemAddr(self: *Vm, table: *const types.TypeTable, base_ty: TypeId, base: Reg, idx_word: Reg, elem_size: usize) Error!Addr {
const data: Addr = blk: {
if (base_ty == .string) break :blk self.machine.readWord(base, table.pointer_size);
if (base_ty == .string) break :blk try self.machine.readWord(base, table.pointer_size);
if (base_ty == .cstring) break :blk base;
if (base_ty.isBuiltin()) {
self.detail = "comptime VM: indexing an unsupported builtin base";
@@ -882,7 +1308,7 @@ pub const Vm = struct {
}
break :blk switch (table.get(base_ty)) {
.array, .pointer, .many_pointer => base,
.slice => self.machine.readWord(base, table.pointer_size),
.slice => try self.machine.readWord(base, table.pointer_size),
else => {
self.detail = "comptime VM: indexing a non-array/pointer/slice base";
return error.Unsupported;
@@ -896,20 +1322,20 @@ pub const Vm = struct {
/// Build a `{ptr, len}` fat pointer (slice/string value) in flat memory and
/// return its address. `ptr` is `pointer_size` bytes at offset 0; `len` is an
/// i64 at offset 8 (the layout `typeSizeBytes` uses for slice/string: 16B).
fn makeSlice(self: *Vm, table: *const types.TypeTable, data: Addr, len: u64) Addr {
fn makeSlice(self: *Vm, table: *const types.TypeTable, data: Addr, len: u64) Error!Addr {
const fp = self.machine.allocBytes(16, 8);
self.machine.writeWord(fp, table.pointer_size, data);
self.machine.writeWord(fp + 8, 8, len);
try self.machine.writeWord(fp, table.pointer_size, data);
try self.machine.writeWord(fp + 8, 8, len);
return fp;
}
/// Read the `.len` field (i64 @ offset 8) of a fat-pointer value at `base`.
fn sliceLen(self: *Vm, base: Addr) u64 {
fn sliceLen(self: *Vm, base: Addr) Error!u64 {
return self.machine.readWord(base + 8, 8);
}
/// Read the `.ptr` field (`pointer_size` @ offset 0) of a fat-pointer at `base`.
fn sliceData(self: *Vm, table: *const types.TypeTable, base: Addr) Addr {
fn sliceData(self: *Vm, table: *const types.TypeTable, base: Addr) Error!Addr {
return self.machine.readWord(base, table.pointer_size);
}
};