Files
sx/src/ir/comptime_vm.zig
agra 45d869da41 fibers B1.2: Io capability + context.io + blocking impl + Future/async/await/cancel
Threads an `Io` capability onto `Context` exactly like `Allocator`: a
`protocol #inline` whose process-wide default is a stateless `CBlockingIo`
(the mirror of `CAllocator`), installed in `__sx_default_context`.

Library (library/modules/std):
- core.sx: `Io` protocol (spawn_raw / suspend_raw / ready / poll / now_ms /
  arm_timer) + `SpawnOpts` / `PinTarget` / `ParkToken`; `Context` gains an
  `io: Io` field LAST (allocator stays index 0, data stays index 1).
- io.sx (new): `CBlockingIo` + `impl Io` (blocking M:1 semantics — now_ms is
  a real monotonic clock, the rest are no-ops/0; suspend never called);
  `Future($R)` { value; state: FutureState; err: IoErr; park; task; canceled:
  Atomic(bool) } with `Value :: R`; the async ergonomic layer
  `async` / `async_void` / `await` (value-carrying `(R, !IoErr)`) / `cancel`.
  Built with the verified `= ---` + field-assign + `Closure(..$args) -> $R` +
  `..$args` idiom (NON-void $R only — Future(void) is deferred per issue 0150).
- std.sx: re-export the Io surface + the io.sx tail.

Compiler (src/ir):
- protocol.zig `emitDefaultContextGlobal` + comptime_vm.zig
  `materializeDefaultContext`: both materializers of `__sx_default_context`
  now build the inline CBlockingIo->Io vtable (7 words) at the new field.
- stmt.zig `lowerPush`: `push Context.{...}` now INHERITS omitted fields from
  the ambient context (seed the slot from current_ctx_ref, overwrite only the
  literal's named fields) — correct capability-bag semantics, so the partial
  `push Context.{ allocator = X }` sites don't zero a null `io` vtable.
- protocols.zig + lower.zig + error_analysis.zig: record protocol-impl method
  names so the "declared `!` but never errors" lint skips a conforming impl
  whose `!` is dictated by the protocol contract (e.g. Io.suspend_raw).

37 `.ir` snapshots regenerated: layout-only (the Context type now carries the
Io field, shifting type-table numbering); no stdout/stderr/exit changes.

The blocking Io + now_ms + Future/async work when `async` is called with the
receiver passed explicitly; the user-facing UFCS form `context.io.async(...)`
is blocked on a separate UFCS generic-inference bug (filed next).

Suite: 726 ran, 0 failed.
2026-06-20 22:21:27 +03:00

2561 lines
150 KiB
Zig

//! Byte-addressable comptime machine — Phase 1 of `current/PLAN-COMPILER-VM.md`.
//!
//! The comptime evaluator is being rebuilt around a byte-addressable memory
//! so comptime values are NATIVE BYTES (like runtime), instead of the tagged
//! `Value` union the legacy interpreter (`interp.zig`) uses. This module is the
//! machine substrate: byte-addressable memory backed by an ARENA of stable host
//! allocations (each `allocBytes` never moves; freed wholesale on `deinit`), plus
//! a per-call `Frame` holding a register file. `Addr` is the allocation's real
//! host pointer, so a comptime pointer and an FFI-returned host pointer are the
//! same kind of value.
//!
//! Value model (grows over later sub-steps): a register (`Reg`) is a raw 64-bit
//! word that is EITHER an immediate scalar (its bits) OR an `Addr` into comptime
//! memory (for aggregates) — interpreted by the IR result type, exactly like a
//! real machine / LLVM. Scalars up to 64 bits (sx's widest is `i64`/`u64`/`f64`)
//! fit a register directly; structs/arrays/slices live in comptime memory and a
//! register holds their address.
//!
//! Target-awareness lives in the EXECUTOR, not here: this module only moves raw
//! bytes. Layout (sizes/offsets/pointer width) is supplied by the type table when
//! the executor lays a value out, so cross-compilation stays correct.
//!
//! `Machine` (arena-backed memory + scalar word read/write + byte views) holds the
//! comptime stack + heap; `Frame` is the per-call register file. A `Frame` does NOT
//! reclaim the machine's memory on exit — a callee can return an aggregate whose
//! register holds an `Addr` into comptime memory, and reclaiming would dangle it. The
//! legacy interpreter remains the live evaluator until the VM reaches parity.
const std = @import("std");
const inst_mod = @import("inst.zig");
const types = @import("types.zig");
const mod_mod = @import("module.zig");
const comptime_value = @import("comptime_value.zig");
const compiler_hooks = @import("compiler_hooks.zig");
const host_ffi = @import("host_ffi.zig");
const errors_mod = @import("../errors.zig");
const Value = comptime_value.Value;
const Inst = inst_mod.Inst;
const Ref = inst_mod.Ref;
const BlockId = inst_mod.BlockId;
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 comptime memory address — a REAL host pointer (`@intFromPtr`), since the
/// machine allocates each object from an arena that never moves it. `null_addr` (0)
/// is the null sentinel (no allocation is ever at address 0), so a zeroed register
/// reads as null — mirroring how the legacy `Value` model distinguishes `null_val`.
/// Because addresses are absolute host pointers, a comptime pointer and an
/// FFI-returned host pointer are the SAME kind of value: the FFI bridge hands them
/// to / from real libc with no translation (Phase 4D).
pub const Addr = u64;
pub const null_addr: Addr = 0;
/// A raw register word: an immediate scalar's bits, or an `Addr`. The IR result
/// type tells the executor which.
pub const Reg = u64;
/// The comptime memory machine: an ARENA of host allocations serving as the
/// comptime stack + heap. Each `allocBytes` is a separate arena allocation that
/// NEVER moves and is freed wholesale on `deinit` (no per-object free — comptime is
/// short-lived). There is NO fixed buffer and NO size cap: the arena grows through
/// its backing allocator on demand. `Addr` is the allocation's REAL host pointer,
/// so a comptime pointer and an FFI-returned host pointer are interchangeable —
/// the FFI bridge passes them to / from libc untouched (Phase 4D).
pub const Machine = struct {
arena: std.heap.ArenaAllocator,
pub fn init(gpa: std.mem.Allocator) Machine {
return .{ .arena = std.heap.ArenaAllocator.init(gpa) };
}
pub fn deinit(self: *Machine) void {
self.arena.deinit();
}
/// Allocate `size` ZEROED bytes aligned to `alignment`; returns the address (a
/// stable host pointer). `size == 0` still yields a valid, non-null address.
/// Over-allocates to honor a RUNTIME alignment (`Allocator.alignedAlloc` needs a
/// comptime alignment) and aligns the base up within the block.
pub fn allocBytes(self: *Machine, size: usize, alignment: usize) Addr {
const a = if (alignment == 0) 1 else alignment;
const n = @max(size, 1);
const raw = self.arena.allocator().alloc(u8, n + a - 1) catch @panic("comptime VM: out of memory");
@memset(raw, 0);
const aligned = std.mem.alignForward(usize, @intFromPtr(raw.ptr), a);
return @intCast(aligned);
}
/// Read a `size`-byte (1/2/4/8) little-endian scalar at `addr` into a register
/// word (zero-extended). A null / oversized access returns `error.OutOfBounds`
/// (NOT a panic) so a malformed comptime run BAILS to the legacy fallback rather
/// than crashing. (Addresses are absolute host pointers, so there is no
/// upper-bound check — a non-null wild address would fault; the `Frame` `bad_ref`
/// guard catches the dominant malformed-IR vector before any such deref.)
pub fn readWord(_: *const Machine, addr: Addr, size: usize) error{OutOfBounds}!Reg {
if (addr == null_addr or size > 8) return error.OutOfBounds;
const p: [*]const u8 = @ptrFromInt(@as(usize, @intCast(addr)));
var buf: [8]u8 = @splat(0);
@memcpy(buf[0..size], p[0..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`. Null-checked → `error.OutOfBounds` (not a panic).
pub fn writeWord(_: *Machine, addr: Addr, size: usize, val: Reg) error{OutOfBounds}!void {
if (addr == null_addr or size > 8) return error.OutOfBounds;
const p: [*]u8 = @ptrFromInt(@as(usize, @intCast(addr)));
var buf: [8]u8 = undefined;
std.mem.writeInt(u64, &buf, val, .little);
@memcpy(p[0..size], buf[0..size]);
}
/// A mutable byte view of `len` bytes at `addr` (for aggregate copies / slice
/// payloads). Null-checked → `error.OutOfBounds`. A zero-length view is always
/// valid. The view stays valid across later `allocBytes` — the arena never moves
/// an allocation.
pub fn bytes(_: *Machine, addr: Addr, len: usize) error{OutOfBounds}![]u8 {
if (len == 0) return &[_]u8{};
if (addr == null_addr) return error.OutOfBounds;
const p: [*]u8 = @ptrFromInt(@as(usize, @intCast(addr)));
return p[0..len];
}
};
/// One call frame: a register file indexed by IR `Ref` index. It does NOT reclaim
/// the machine stack on exit — a callee can return an aggregate whose value is an
/// `Addr` into comptime memory, and reclaiming the callee's region would dangle it.
/// Comptime evaluation is bounded, so all allocations live until `Vm.deinit`;
/// `Machine.mark`/`reset` remain for explicit scoped use. The register file IS
/// per-call (each `run` gets a fresh one sized to its callee's Ref space).
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)");
@memset(regs, 0);
return .{ .regs = regs, .gpa = gpa };
}
pub fn deinit(self: *Frame) void {
self.gpa.free(self.regs);
}
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
/// comptime 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 comptime memory (freed here on return).
///
/// 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, build_config: ?*compiler_hooks.BuildConfig, source_map: ?*const std.StringHashMap([:0]const u8)) ?Value {
last_bail_reason = null;
const func = module.getFunction(func_id);
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;
vm.build_config = build_config;
vm.source_map = source_map;
// `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 comptime 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;
};
}
/// Run a post-link build callback on the VM (the post-codegen build driver — see
/// `core.invokeByFuncId`). Like `tryEval`, but for a callback that may take the
/// opaque `BuildOptions` handle as an explicit arg (the `on_build(cb)` form,
/// `cb: (opt: BuildOptions) -> bool`): when `pass_options` is set, the handle (a
/// null sentinel — the real state is the threaded `BuildConfig`) is passed after
/// the implicit ctx. Returns null on a bail (`last_bail_reason` names the cause).
pub fn runBuildCallback(gpa: std.mem.Allocator, module: *const Module, func_id: inst_mod.FuncId, build_config: ?*compiler_hooks.BuildConfig, source_map: ?*const std.StringHashMap([:0]const u8), pass_options: bool) ?Value {
last_bail_reason = null;
const func = module.getFunction(func_id);
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;
vm.build_config = build_config;
vm.source_map = source_map;
const extra: []const Reg = if (pass_options) &.{null_addr} else &.{};
const reg = vm.runEntryArgs(func_id, extra) catch |err| {
last_bail_reason = vm.detail orelse @errorName(err);
return null;
};
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 ────────────────────────────────────────────────────────────────
//
// Walks the SAME SSA IR the legacy interpreter (`interp.zig`) walks, but over
// comptime frames: each SSA result is a `Reg` word (immediate scalar bits, or
// an `Addr`). Scalar semantics MIRROR the legacy interp so the two evaluators
// agree byte-for-byte (the parity goal): integer math is 64-bit wrapping/signed
// (`+%`, `@divTrunc`, signed compares — the legacy's `.int` is i64 regardless of
// 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, OutOfBounds };
fn isFloat(ty: TypeId) bool {
return ty == .f32 or ty == .f64;
}
/// The nominal identity (`name` + stable `nominal_id`) of a `declare_type`'d slot —
/// from the forward `tagged_union` OR an already-completed nominal (so a re-fill
/// preserves identity). Mirrors `compiler_lib.nominalIdent`. Null for a non-nominal
/// handle (not a `declare_type` result).
fn nominalIdentOf(info: types.TypeInfo) ?struct { name: types.StringId, nominal_id: u32 } {
return switch (info) {
.tagged_union => |u| .{ .name = u.name, .nominal_id = u.nominal_id },
.@"enum" => |e| .{ .name = e.name, .nominal_id = e.nominal_id },
.@"struct" => |s| .{ .name = s.name, .nominal_id = s.nominal_id },
.tuple => .{ .name = types.StringId.empty, .nominal_id = 0 }, // structural; name vestigial
else => null,
};
}
/// A `{ name: string, ty: Type }` member decoded from comptime memory — the shared
/// shape of a compiler-API `Member`, a metatype `EnumVariant { name, payload }`,
/// and a `StructField { name, type }` (all 2-field `{ string, Type }` structs).
const NamedMember = struct { name: types.StringId, ty: TypeId };
/// 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);
}
// ── BuildOptions target predicates (Phase 5.5) ───────────────────────────────
// Computed from the `--target` triple, mirroring `compiler_hooks`'s legacy hooks
// (which mirror `TargetConfig.is{MacOS,IOS,IOSDevice,IOSSimulator}()`).
fn tripleHas(triple: ?[]const u8, needle: []const u8) bool {
const t = triple orelse return false;
return std.mem.indexOf(u8, t, needle) != null;
}
fn predIsIOS(triple: ?[]const u8) bool {
return tripleHas(triple, "apple-ios");
}
fn predIsMacOS(triple: ?[]const u8) bool {
if (predIsIOS(triple)) return false;
return tripleHas(triple, "apple-macosx") or tripleHas(triple, "apple-macos") or tripleHas(triple, "apple-darwin");
}
fn predIsIOSDevice(triple: ?[]const u8) bool {
return predIsIOS(triple) and !tripleHas(triple, "simulator");
}
fn predIsIOSSimulator(triple: ?[]const u8) bool {
return predIsIOS(triple) and tripleHas(triple, "simulator");
}
fn predIsAndroid(triple: ?[]const u8) bool {
return tripleHas(triple, "android");
}
/// Map a BuildOptions predicate name (`is_macos`/…) to its triple-test, or null.
fn boolPredicate(name: []const u8) ?*const fn (?[]const u8) bool {
if (std.mem.eql(u8, name, "is_macos")) return predIsMacOS;
if (std.mem.eql(u8, name, "is_ios")) return predIsIOS;
if (std.mem.eql(u8, name, "is_ios_device")) return predIsIOSDevice;
if (std.mem.eql(u8, name, "is_ios_simulator")) return predIsIOSSimulator;
if (std.mem.eql(u8, name, "is_android")) return predIsAndroid;
return null;
}
pub const Vm = struct {
machine: Machine,
gpa: std.mem.Allocator,
/// The type table — supplies TARGET-aware layout (sizes/alignments/field
/// offsets keyed off `pointer_size`) for memory + aggregate ops. Optional so
/// scalar-only runs need no table; memory ops bail loudly if it is absent.
table: ?*const types.TypeTable = null,
/// The module — resolves a `call`'s callee `FuncId` to its `Function`. Optional
/// so leaf functions (no calls) need none; a `call` bails loudly if it is absent.
module: ?*const Module = null,
/// The mutable build configuration (`BuildOptions` accumulator) — the SAME
/// `BuildConfig` `EmitLLVM` owns and `main.zig` reads post-link. Threaded in at
/// the `#run`/const-init eval sites so an `abi(.compiler)` `BuildOptions` function
/// (e.g. `set_post_link_callback`) records into it directly. Null at lowering-time
/// type-fn evals (no build config exists yet); such a function bails loudly.
build_config: ?*compiler_hooks.BuildConfig = null,
/// File → source text (the diagnostics' `import_sources`), threaded from the host
/// so `trace_resolve` can turn a packed `(func_id, span.start)` comptime frame into
/// `file:line:col` + the source line. Null → line/col degrade to 1 / "".
source_map: ?*const std.StringHashMap([:0]const u8) = null,
/// Current call-recursion depth, guarded against host stack overflow on deep /
/// infinite comptime recursion (mirrors the legacy interp's `call_depth`).
depth: u32 = 0,
/// Reason for the last `error.Unsupported` / `error.TypeError` bail — the op
/// 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, .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 {
return self.runEntryArgs(func_id, &.{});
}
/// Run a comptime entry with the materialized implicit `*Context` (when the
/// function has one) PREPENDED to `extra` explicit arg words. A nullary
/// const-init / `#run` passes `extra = &.{}`; a post-link build callback of
/// the `on_build` form passes the opaque `BuildOptions` handle.
fn runEntryArgs(self: *Vm, func_id: FuncId, extra: []const Reg) 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: std.ArrayList(Reg) = .empty;
defer argbuf.deinit(self.gpa);
if (func.has_implicit_ctx) {
argbuf.append(self.gpa, try self.materializeDefaultContext(module)) catch @panic("comptime VM: out of memory (entry args)");
}
for (extra) |a| argbuf.append(self.gpa, a) catch @panic("comptime VM: out of memory (entry args)");
if (argbuf.items.len != func.params.len)
return self.failMsg("comptime VM: entry arg count mismatch (ctx + explicit args vs params)");
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, argbuf.items);
}
/// Materialize the default `Context` in comptime 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 comptime 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` (comptime 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;
}
// No `__sx_default_context` global yet — the LOWERING-time path (a type-fn
// const runs at scanDecls, before Pass 1c emits that global). Build the
// REAL default context directly from the CAllocator→Allocator thunks
// (forced to exist by `runComptimeTypeFunc` before this runs), mirroring
// the legacy `defaultContextValue` / `emitDefaultContextGlobal`: the inline
// `Allocator` value is `{ ctx: *void = null, alloc_fn, dealloc_fn }` (three
// pointer-sized words) at the head of `Context`, so `context.allocator`
// dispatches `alloc_bytes` → `call_indirect` → the thunk → native `malloc`,
// all on the VM. If a thunk is absent (std not imported), the field stays
// null (zeroed) and an allocating body bails — same as a non-std program.
const ctx_name = @constCast(table).internString("Context");
const ctx_ty = table.findByName(ctx_name) orelse
return self.failMsg("comptime VM: no Context type to materialize the implicit context");
const addr = self.machine.allocBytes(table.typeSizeBytes(ctx_ty), table.typeAlignBytes(ctx_ty)); // zeroed
const ps: Addr = table.pointer_size;
if (self.findFuncByName(module, "__thunk_CAllocator_Allocator_alloc_bytes")) |fid|
try self.machine.writeWord(addr + ps, ps, funcRefWord(fid)); // allocator.alloc_fn @ +ptr_size
if (self.findFuncByName(module, "__thunk_CAllocator_Allocator_dealloc_bytes")) |fid|
try self.machine.writeWord(addr + 2 * ps, ps, funcRefWord(fid)); // allocator.dealloc_fn @ +2*ptr_size
// Context layout: { allocator(3 words), data(1 word), io }. The inline
// `Io` value starts at +4*ptr_size: { ctx, fn0..fn5 }, receiver ctx is
// null (CBlockingIo stateless), the 6 method func-refs follow in the
// protocol's declaration order. Mirrors the `emitDefaultContextGlobal`
// global path; absent thunks (std not imported) leave the field zeroed.
const io_base: Addr = addr + 4 * ps;
const io_methods = [_][]const u8{
"__thunk_CBlockingIo_Io_spawn_raw",
"__thunk_CBlockingIo_Io_suspend_raw",
"__thunk_CBlockingIo_Io_ready",
"__thunk_CBlockingIo_Io_poll",
"__thunk_CBlockingIo_Io_now_ms",
"__thunk_CBlockingIo_Io_arm_timer",
};
for (io_methods, 0..) |mname, i| {
if (self.findFuncByName(module, mname)) |fid|
try self.machine.writeWord(io_base + (@as(Addr, @intCast(i)) + 1) * ps, ps, funcRefWord(fid));
}
return addr;
}
/// Find a module function by its exact name → its `FuncId`, or null. Used to
/// resolve the CAllocator thunk func-refs for the lowering-time default context.
fn findFuncByName(_: *Vm, module: *const Module, name: []const u8) ?inst_mod.FuncId {
for (module.functions.items, 0..) |*f, i| {
if (std.mem.eql(u8, module.types.getString(f.name), name)) return inst_mod.FuncId.fromIndex(@intCast(i));
}
return null;
}
/// Lay a static `ConstantValue` of type `ty` into comptime 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.
pub fn run(self: *Vm, func: *const Function, args: []const Reg) Error!Reg {
if (self.depth >= max_depth) {
self.detail = "comptime VM: call recursion too deep";
return error.Unsupported;
}
self.depth += 1;
defer self.depth -= 1;
// The Ref index space is flat: params first, then every block's
// instructions in block order (each `block.first_ref` is its base). Size
// the register file + a parallel Ref→type map to it.
var total: usize = func.params.len;
for (func.blocks.items) |blk| total += blk.insts.items.len;
const ref_types = self.gpa.alloc(TypeId, total) catch @panic("comptime VM: out of memory (ref types)");
defer self.gpa.free(ref_types);
for (func.params, 0..) |p, i| ref_types[i] = p.ty;
for (func.blocks.items) |blk| {
for (blk.insts.items, 0..) |ins, j| ref_types[@as(usize, blk.first_ref) + j] = ins.ty;
}
var frame = Frame.init(self.gpa, total);
defer frame.deinit();
for (args, 0..) |a, i| frame.set(i, a);
var current = BlockId.fromIndex(0);
// Branch args are passed as Refs (not resolved values): the same frame
// persists, and a target block's `block_param`s — its first instructions —
// read the source registers before anything overwrites them (SSA: a block
// only writes its own Ref range).
var block_args: []const Ref = &.{};
while (true) {
// A malformed branch target (out-of-range block) bails, not panics.
if (current.index() >= func.blocks.items.len) return self.badRef();
const blk = &func.blocks.items[current.index()];
var ref: usize = blk.first_ref;
var jumped = false;
for (blk.insts.items) |*ins| {
if (ins.op == .block_param) {
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;
}
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;
},
.jump => |j| {
current = j.target;
block_args = j.args;
jumped = true;
break;
},
.ret => |w| return w,
.ret_void => return 0,
}
}
if (!jumped) return 0; // fell off the block with no terminator → void
}
}
const Step = union(enum) {
value: Reg,
jump: struct { target: BlockId, args: []const Ref },
ret: Reg,
ret_void,
};
fn exec(self: *Vm, ins: *const Inst, frame: *Frame, ref_types: []const TypeId) Error!Step {
switch (ins.op) {
// ── Constants ───────────────────────────────────────
.const_int => |v| return .{ .value = @bitCast(v) },
.const_bool => |v| return .{ .value = @intFromBool(v) },
.const_float => |v| return .{ .value = @bitCast(v) },
.const_null, .const_undef => return .{ .value = null_addr },
// A `Type` literal: the 8-byte handle is the `TypeId` index in a word
// (the `.type_value` representation). `regToValue` maps it back to a
// `.type_tag` Value at the legacy boundary.
.const_type => |tid| return .{ .value = @as(Reg, tid.index()) },
// ── Arithmetic ──────────────────────────────────────
.add, .sub, .mul, .div, .mod => |b| return .{
.value = try arith(std.meta.activeTag(ins.op), ins.ty, frame.get(b.lhs.index()), frame.get(b.rhs.index())),
},
// ── Bitwise + shift (i64, mirroring the legacy interp) ─
.bit_and, .bit_or, .bit_xor, .shl, .shr => |b| return .{
.value = bitwise(std.meta.activeTag(ins.op), frame.get(b.lhs.index()), frame.get(b.rhs.index())),
},
.bit_not => |u| return .{ .value = @bitCast(~@as(i64, @bitCast(frame.get(u.operand.index())))) },
.neg => |u| {
const x = frame.get(u.operand.index());
if (isFloat(ins.ty)) return .{ .value = @bitCast(-@as(f64, @bitCast(x))) };
return .{ .value = @bitCast(-%@as(i64, @bitCast(x))) };
},
// ── Comparison (operand type drives signedness/kind) ─
.cmp_eq, .cmp_ne, .cmp_lt, .cmp_le, .cmp_gt, .cmp_ge => |b| {
const r = try self.cmp(std.meta.activeTag(ins.op), (try self.refTy(ref_types, b.lhs)), frame.get(b.lhs.index()), frame.get(b.rhs.index()));
return .{ .value = @intFromBool(r) };
},
// ── Logical (operands already evaluated) ────────────
.bool_and => |b| return .{ .value = @intFromBool(frame.get(b.lhs.index()) != 0 and frame.get(b.rhs.index()) != 0) },
.bool_or => |b| return .{ .value = @intFromBool(frame.get(b.lhs.index()) != 0 or frame.get(b.rhs.index()) != 0) },
.bool_not => |u| return .{ .value = @intFromBool(frame.get(u.operand.index()) == 0) },
// ── Conversions ─────────────────────────────────────
// widen/narrow/bitcast pass the bits through (comptime values don't
// truncate — matches the legacy interp). int↔float DO convert.
.widen, .narrow, .bitcast => |c| return .{ .value = frame.get(c.operand.index()) },
.int_to_float => |c| return .{ .value = @bitCast(@as(f64, @floatFromInt(@as(i64, @bitCast(frame.get(c.operand.index())))))) },
.float_to_int => |c| return .{ .value = @bitCast(@as(i64, @intFromFloat(@as(f64, @bitCast(frame.get(c.operand.index())))))) },
// ── Memory + structs (flat layout, target-aware) ────
.alloca => |t| {
const table = try self.requireTable();
return .{ .value = self.machine.allocBytes(table.typeSizeBytes(t), table.typeAlignBytes(t)) };
},
.load => |u| {
const table = try self.requireTable();
return .{ .value = try self.readField(table, frame.get(u.operand.index()), ins.ty) };
},
.store => |s| {
const table = try self.requireTable();
const vty = if (s.val_ty != .void) s.val_ty else (try self.refTy(ref_types, s.val));
try self.writeField(table, frame.get(s.ptr.index()), vty, frame.get(s.val.index()));
return .{ .value = 0 }; // store has a void result but still occupies a Ref slot
},
// Comptime is single-threaded, so seq_cst is trivially satisfied —
// atomic load/store are ordinary load/store here (the ordering is
// a no-op at comptime). Mirrors the design (§3): the interp needs no
// atomics machinery.
.atomic_load => |a| {
const table = try self.requireTable();
return .{ .value = try self.readField(table, frame.get(a.ptr.index()), ins.ty) };
},
.atomic_store => |a| {
const table = try self.requireTable();
const vty = if (a.val_ty != .void) a.val_ty else (try self.refTy(ref_types, a.val));
try self.writeField(table, frame.get(a.ptr.index()), vty, frame.get(a.val.index()));
return .{ .value = 0 };
},
// RMW at comptime (single-thread): load old, compute new, store new,
// return old — the ordering is a no-op. min/max pick signed vs
// unsigned compare from the value type.
.atomic_rmw => |a| {
const table = try self.requireTable();
const vty = if (a.val_ty != .void) a.val_ty else ins.ty;
const old = try self.readField(table, frame.get(a.ptr.index()), ins.ty);
const operand = frame.get(a.operand.index());
const new_val: Reg = switch (a.kind) {
.add => old +% operand,
.sub => old -% operand,
.@"and" => old & operand,
.@"or" => old | operand,
.xor => old ^ operand,
.min, .max => blk: {
// `Reg` is u64, so `@max`/`@min` on it is an UNSIGNED
// compare. For a signed type, reinterpret as i64 first so
// a negative value loses to a positive one — matching LLVM
// `atomicrmw min`/`max` (signed) and the emit side.
const want_max = a.kind == .max;
if (table.isUnsignedInt(vty)) {
break :blk if (want_max) @max(old, operand) else @min(old, operand);
}
const so: i64 = @bitCast(old);
const sp: i64 = @bitCast(operand);
break :blk @bitCast(if (want_max) @max(so, sp) else @min(so, sp));
},
.xchg => operand, // swap: new value IS the operand
};
try self.writeField(table, frame.get(a.ptr.index()), vty, new_val);
return .{ .value = old };
},
// Compare-exchange at comptime (single-thread): read actual, compare
// to cmp, and on equality store new. The ordering is a no-op; `weak`
// behaves as a strong exchange (no spurious failure with one thread).
// Result is `?T` (ins.ty): SUCCESS → none, FAILURE → some(actual).
// Integer T only (the recognizer's guard rules out pointer optionals),
// so the optional is laid out as payload@0 + has_value flag — exactly
// like the `optional_wrap` arm below.
.atomic_cmpxchg => |a| {
const table = try self.requireTable();
const elem_ty = if (a.val_ty != .void) a.val_ty else return self.failMsg("comptime compare_exchange: missing element type");
const ptr = frame.get(a.ptr.index());
const actual = try self.readField(table, ptr, elem_ty);
const cmp_val = frame.get(a.cmp.index());
const success = actual == cmp_val;
if (success) try self.writeField(table, ptr, elem_ty, frame.get(a.new.index()));
// Build the `?T` result in VM memory.
const opt_ty = ins.ty; // ?T
const addr = self.machine.allocBytes(table.typeSizeBytes(opt_ty), table.typeAlignBytes(opt_ty));
// writeWord(addr, SIZE, val): write the 1-byte has_value flag
// EXPLICITLY (size=1) — never rely on alloc zero-init for the
// success/null case (a size=0 write is a no-op, correct only by
// accident; REJECTED-PATTERNS "coincidentally correct").
const has_value_off = addr + table.typeSizeBytes(elem_ty);
if (success) {
try self.machine.writeWord(has_value_off, 1, 0); // has_value = 0 (null)
} else {
try self.writeField(table, addr, elem_ty, actual); // payload = actual
try self.machine.writeWord(has_value_off, 1, 1); // has_value = 1
}
return .{ .value = addr };
},
// A fence is a no-op at comptime (single-thread → nothing to order).
.atomic_fence => return .{ .value = 0 },
.struct_init => |agg| {
const table = try self.requireTable();
const sty = ins.ty;
// `string`/`any` are builtin TWO-WORD aggregates (`{ptr@0, len@8}` /
// `{tag@0, value@8}`) — a literal like `string.{ ptr = p, len = n }`
// (e.g. `from_cstring`) struct_inits one. Lay each operand as an
// 8-byte word; the other builtins have no aggregate literal form.
if (sty == .string or sty == .any) {
const a = self.machine.allocBytes(16, 8);
for (agg.fields, 0..) |fr, i| {
if (i >= 2) break;
try self.machine.writeWord(a + @as(Addr, @intCast(i)) * 8, 8, frame.get(fr.index()));
}
return .{ .value = a };
}
if (sty.isBuiltin()) return self.failMsg("comptime VM: struct_init at a builtin result type");
const addr = self.machine.allocBytes(table.typeSizeBytes(sty), table.typeAlignBytes(sty));
// `struct_init` is the generic aggregate-literal op — its result
// type may be a struct, an ARRAY (e.g. `EnumVariant.[ … ]`), or a
// tuple. Lay each operand out at the matching offset; bail loudly on
// any other shape (never a `.@"struct"`-union-access panic).
switch (table.get(sty)) {
.@"struct" => |s| for (s.fields, 0..) |f, i| {
if (i >= agg.fields.len) break;
try self.writeField(table, addr + fieldOffset(table, sty, @intCast(i)), f.ty, frame.get(agg.fields[i].index()));
},
.array => |a| {
const esz: Addr = @intCast(table.typeSizeBytes(a.element));
for (agg.fields, 0..) |fr, i| try self.writeField(table, addr + @as(Addr, @intCast(i)) * esz, a.element, frame.get(fr.index()));
},
.tuple => |t| for (t.fields, 0..) |fty, i| {
if (i >= agg.fields.len) break;
try self.writeField(table, addr + tupleFieldOffset(table, sty, @intCast(i)), fty, frame.get(agg.fields[i].index()));
},
else => return self.failMsg("comptime VM: struct_init at a non-aggregate result type"),
}
return .{ .value = addr };
},
.struct_get => |fa| {
const table = try self.requireTable();
const sty = try self.aggType(table, fa, ref_types);
// 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| {
const table = try self.requireTable();
const sty = try self.aggType(table, fa, ref_types);
return .{ .value = frame.get(fa.base.index()) + fieldOffset(table, sty, fa.field_index) };
},
// ── Tuples (positional aggregates) ──────────────────
.tuple_init => |agg| {
const table = try self.requireTable();
const tty = ins.ty;
const addr = self.machine.allocBytes(table.typeSizeBytes(tty), table.typeAlignBytes(tty));
const elems = table.get(tty).tuple.fields;
for (elems, 0..) |fty, i| {
if (i >= agg.fields.len) break;
try self.writeField(table, addr + tupleFieldOffset(table, tty, @intCast(i)), fty, frame.get(agg.fields[i].index()));
}
return .{ .value = addr };
},
.tuple_get => |fa| {
const table = try self.requireTable();
const tty = try self.aggType(table, fa, ref_types);
const fty = table.get(tty).tuple.fields[fa.field_index];
return .{ .value = try self.readField(table, frame.get(fa.base.index()) + tupleFieldOffset(table, tty, fa.field_index), fty) };
},
// ── Arrays (contiguous, elem-size stride) ───────────
.index_get => |b| {
const table = try self.requireTable();
const addr = try self.elemAddr(table, (try self.refTy(ref_types, b.lhs)), frame.get(b.lhs.index()), frame.get(b.rhs.index()), table.typeSizeBytes(ins.ty));
return .{ .value = try self.readField(table, addr, ins.ty) };
},
.index_gep => |b| {
const table = try self.requireTable();
const elem_ty = pointeeOf(table, ins.ty);
return .{ .value = try self.elemAddr(table, (try self.refTy(ref_types, b.lhs)), frame.get(b.lhs.index()), frame.get(b.rhs.index()), table.typeSizeBytes(elem_ty)) };
},
.length => |u| {
const table = try self.requireTable();
const oty = (try self.refTy(ref_types, u.operand));
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 = try self.sliceLen(frame.get(u.operand.index())) },
else => {},
}
}
self.detail = "comptime VM: length() on a non-array/slice/string operand";
return error.Unsupported;
},
// ── Slices + strings ({ptr,len} fat pointers) ───────
.const_string => |sid| {
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(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 = (try self.refTy(ref_types, u.operand));
if (oty == .string or (!oty.isBuiltin() and table.get(oty) == .slice))
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;
},
.array_to_slice => |u| {
const table = try self.requireTable();
var aty = (try self.refTy(ref_types, u.operand));
if (!aty.isBuiltin() and table.get(aty) == .pointer) aty = table.get(aty).pointer.pointee;
if (aty.isBuiltin() or table.get(aty) != .array) {
self.detail = "comptime VM: array_to_slice on a non-array operand";
return error.Unsupported;
}
return .{ .value = try self.makeSlice(table, frame.get(u.operand.index()), table.get(aty).array.length) };
},
.subslice => |s| {
const table = try self.requireTable();
const base = frame.get(s.base.index());
const lo: u64 = @bitCast(frame.get(s.lo.index()));
const hi: u64 = @bitCast(frame.get(s.hi.index()));
const bty = if (s.base_ty != .void) s.base_ty else (try self.refTy(ref_types, s.base));
var elem: TypeId = .u8;
var data: Addr = base;
if (bty == .string) {
data = try self.sliceData(table, base);
} else if (!bty.isBuiltin()) {
switch (table.get(bty)) {
.array => |a| elem = a.element,
// `[*]T` (a List's `items` field) / `*T`: the base IS the
// data pointer; subslicing yields `{ base + lo, hi - lo }`.
.many_pointer => |mp| elem = mp.element,
.pointer => |p| elem = p.pointee,
.slice => |sl| {
elem = sl.element;
data = try self.sliceData(table, base);
},
else => {
self.detail = "comptime VM: subslice on a non-array/slice/string base";
return error.Unsupported;
},
}
} else {
self.detail = "comptime VM: subslice on an unsupported base";
return error.Unsupported;
}
const esz: u64 = @intCast(table.typeSizeBytes(elem));
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 = 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) };
},
// ── Optionals ───────────────────────────────────────
.optional_wrap => |u| {
const table = try self.requireTable();
const child = table.get(ins.ty).optional.child; // ins.ty is ?T
const val = frame.get(u.operand.index());
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
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 = (try self.refTy(ref_types, u.operand));
const v = frame.get(u.operand.index());
if (!try self.optHas(table, opt_ty, v)) {
self.detail = "comptime VM: unwrap of a null optional";
return error.TypeError;
}
const child = table.get(opt_ty).optional.child;
if (optChildIsPtr(table, child)) return .{ .value = v };
return .{ .value = try self.readField(table, v, child) };
},
.optional_has_value => |u| {
const table = try self.requireTable();
return .{ .value = @intFromBool(try self.optHas(table, (try self.refTy(ref_types, u.operand)), frame.get(u.operand.index()))) };
},
.optional_coalesce => |b| {
const table = try self.requireTable();
const opt_ty = (try self.refTy(ref_types, b.lhs));
const v = frame.get(b.lhs.index());
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) };
}
return .{ .value = frame.get(b.rhs.index()) };
},
// ── Enums (payloadless: the tag is the value) ───────
.enum_init => |ei| {
if (ei.payload.isNone()) return .{ .value = @as(Reg, ei.tag) };
// Tagged union { tag@0, payload@tag_size } — `{ header, [N x i8] }`
// in the LLVM layout (see backend/llvm/types.zig). Allocate the
// whole value (zeroed: the payload area is max-payload sized, so a
// smaller variant leaves the tail zero), write the tag at offset 0,
// and copy the payload bytes in at `tag_size`.
const table = try self.requireTable();
const uty = ins.ty;
if (uty.isBuiltin() or table.get(uty) != .tagged_union)
return self.failMsg("comptime VM: enum_init-with-payload on a non-tagged-union result type not supported");
const tu = table.get(uty).tagged_union;
// The simple `{ header(tag)@0, [N x i8] payload@tag_size }` layout
// assumed below holds ONLY for a tag_type-headed tagged union. A
// `backing_type` union is laid out as the backing STRUCT (header from
// all-but-last fields, payload = last field) — different offsets — so
// bail loudly rather than write the payload to the wrong place.
if (tu.backing_type != null)
return self.failMsg("comptime VM: enum_init on a backing_type tagged union not yet ported (layout differs)");
const size = table.typeSizeBytes(uty);
const addr = self.machine.allocBytes(size, table.typeAlignBytes(uty));
@memset(try self.machine.bytes(addr, size), 0);
try self.writeField(table, addr, tu.tag_type, @as(Reg, ei.tag));
const tag_size: Addr = @intCast(table.typeSizeBytes(tu.tag_type));
const payload_ty = try self.refTy(ref_types, ei.payload);
try self.writeField(table, addr + tag_size, payload_ty, frame.get(ei.payload.index()));
return .{ .value = addr };
},
.enum_tag => |u| {
const oty = (try self.refTy(ref_types, u.operand));
const v = frame.get(u.operand.index());
if (oty.isBuiltin()) return .{ .value = v }; // already an integer tag
const table = try self.requireTable();
if (table.get(oty) == .@"enum") return .{ .value = v }; // payloadless: word IS the tag
if (table.get(oty) == .tagged_union) {
// `{ tag@0, payload@tag_size }` — read the tag word from the
// value's address. A `backing_type` union lays the tag out
// differently (it's a field of the backing struct), so bail
// rather than read the wrong bytes.
const tu = table.get(oty).tagged_union;
if (tu.backing_type != null) {
self.detail = "comptime VM: enum_tag on a backing_type tagged union not yet ported (layout differs)";
return error.Unsupported;
}
return .{ .value = try self.readField(table, v, tu.tag_type) };
}
self.detail = "comptime VM: enum_tag on an unexpected operand type";
return error.Unsupported;
},
// Extract a tagged union's active payload — the bytes at `tag_size`,
// read as the variant's payload type. Mirrors the `enum_init` write
// layout (`{ tag@0, [N x i8] payload@tag_size }`). The match-arm
// capture binding (`case .v: (x)`) uses this.
.enum_payload => |fa| {
const oty = (try self.refTy(ref_types, fa.base));
const base = frame.get(fa.base.index());
const table = try self.requireTable();
if (oty.isBuiltin() or table.get(oty) != .tagged_union) {
self.detail = "comptime VM: enum_payload on a non-tagged-union operand";
return error.Unsupported;
}
const tu = table.get(oty).tagged_union;
if (tu.backing_type != null) {
self.detail = "comptime VM: enum_payload on a backing_type tagged union not yet ported (layout differs)";
return error.Unsupported;
}
if (fa.field_index >= tu.fields.len)
return self.failMsg("comptime VM: enum_payload variant index out of range");
const payload_ty = tu.fields[fa.field_index].ty;
const tag_size: Addr = @intCast(table.typeSizeBytes(tu.tag_type));
return .{ .value = try self.readField(table, base + tag_size, payload_ty) };
},
// `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) };
},
// Dump the comptime call-frame chain (`trace.print_interpreter_frames`) —
// the VM-native mirror of the legacy `printInterpFrames`. Walks the active
// `call_stack` (skipping the last frame, the `print_interpreter_frames`
// fn itself, like the legacy) and writes ` at <name>` lines straight to
// fd 1 (consistent with `out`'s now-direct libc `write`).
.interp_print_frames => {
const module = self.module orelse return self.failMsg("comptime interp_print_frames: no module");
const n = self.call_stack.items.len;
if (n <= 1) return .{ .value = null_addr };
var buf = std.ArrayList(u8).empty;
defer buf.deinit(self.gpa);
buf.appendSlice(self.gpa, "comptime call frames (most recent call last):\n") catch return self.failMsg("comptime interp_print_frames: out of memory");
var i: usize = 0;
while (i < n - 1) : (i += 1) {
const fname = module.types.getString(module.getFunction(self.call_stack.items[i]).name);
buf.appendSlice(self.gpa, " at ") catch return self.failMsg("comptime interp_print_frames: out of memory");
buf.appendSlice(self.gpa, fname) catch return self.failMsg("comptime interp_print_frames: out of memory");
buf.append(self.gpa, '\n') catch return self.failMsg("comptime interp_print_frames: out of memory");
}
_ = std.c.write(1, buf.items.ptr, buf.items.len);
return .{ .value = null_addr };
},
// Unpack a comptime frame `(func_id << 32 | span.start)` and build a
// `Frame { file, line, col, func, line_text }` aggregate in comptime memory —
// the VM-native mirror of the legacy interp's `.trace_resolve`. `ins.ty`
// is the `Frame` struct, so each field's type/offset comes from the table.
.trace_resolve => |u| {
const table = try self.requireTable();
const module = self.module orelse return self.failMsg("comptime trace_resolve: no module");
const raw = frame.get(u.operand.index());
const fid: u32 = @intCast(raw >> 32);
const offset: u32 = @truncate(raw);
if (fid >= module.functions.items.len) return self.failMsg("comptime trace_resolve: func id out of range");
const func = module.getFunction(inst_mod.FuncId.fromIndex(fid));
const func_name = module.types.getString(func.name);
const file_full = func.source_file orelse "";
const file = std.fs.path.basename(file_full);
var line: i64 = 1;
var col: i64 = 1;
var line_text: []const u8 = "";
if (self.source_map) |sm| {
if (sm.get(file_full)) |src| {
const loc = errors_mod.SourceLoc.compute(src, offset);
line = @intCast(loc.line);
col = @intCast(loc.col);
line_text = errors_mod.lineAt(src, offset);
}
}
const fty = ins.ty;
if (fty.isBuiltin() or table.get(fty) != .@"struct")
return self.failMsg("comptime trace_resolve: result type is not a Frame struct");
const sfields = table.get(fty).@"struct".fields;
if (sfields.len != 5) return self.failMsg("comptime trace_resolve: Frame struct is not 5 fields");
const addr = self.machine.allocBytes(table.typeSizeBytes(fty), table.typeAlignBytes(fty));
// { file, line, col, func, line_text } — positional, matching the legacy build.
try self.writeField(table, addr + fieldOffset(table, fty, 0), sfields[0].ty, try self.makeStringValue(table, file));
try self.writeField(table, addr + fieldOffset(table, fty, 1), sfields[1].ty, @bitCast(line));
try self.writeField(table, addr + fieldOffset(table, fty, 2), sfields[2].ty, @bitCast(col));
try self.writeField(table, addr + fieldOffset(table, fty, 3), sfields[3].ty, try self.makeStringValue(table, func_name));
try self.writeField(table, addr + fieldOffset(table, fty, 4), sfields[4].ty, try self.makeStringValue(table, line_text));
return .{ .value = addr };
},
// `error_tag_name(e)` — the runtime tag id (a word) → its name string via
// the always-linked tag-name table. Pure: builds a `{ptr,len}` string in
// comptime memory. Mirrors the legacy interp's `error_tag_name_get`.
.error_tag_name_get => |u| {
const table = try self.requireTable();
const id: u32 = @intCast(frame.get(u.operand.index()));
return .{ .value = try self.makeStringValue(table, table.getTagName(id)) };
},
// ── Calls ───────────────────────────────────────────
// Direct call: resolve the static callee `FuncId` and dispatch.
.call => |c| return .{ .value = try self.invoke(c.callee, c.args, frame, ref_types, ins.ty) },
// 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;
};
return .{ .value = try self.invoke(fid, ci.args, frame, ref_types, ins.ty) };
},
// ── 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) },
// `&global` — only `&__sx_default_context` is materialised at comptime
// (its address sees runtime use via the implicit-ctx plumbing). Return
// the context's comptime address — an aggregate value IS its address,
// so a later `load`/field read sees the materialised Context. Mirrors the
// legacy interp's `global_addr` (the sole supported global); any other
// global bails to legacy fallback.
.global_addr => |gid| {
const module = self.module orelse return self.failMsg("comptime VM: global_addr needs a module");
if (gid.index() < module.globals.items.len and
std.mem.eql(u8, module.types.getString(module.globals.items[gid.index()].name), "__sx_default_context"))
{
return .{ .value = try self.materializeDefaultContext(module) };
}
return self.failMsg("comptime global_addr: only `&__sx_default_context` is materialised at comptime");
},
// 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).
.addr_of => |u| return .{ .value = frame.get(u.operand.index()) },
// `p.*` — read the pointee (like `load`); `ins.ty` is the pointee type.
.deref => |u| {
const table = try self.requireTable();
return .{ .value = try self.readField(table, frame.get(u.operand.index()), ins.ty) };
},
// ── Terminators ─────────────────────────────────────
.br => |b| return .{ .jump = .{ .target = b.target, .args = b.args } },
.cond_br => |b| {
if (frame.get(b.cond.index()) != 0) return .{ .jump = .{ .target = b.then_target, .args = b.then_args } };
return .{ .jump = .{ .target = b.else_target, .args = b.else_args } };
},
// Multi-way branch on an integer discriminant: an enum/error tag, or a
// type-category match where the operand is a `.type_value` whose word IS
// its `TypeId` index (so the same i64 compare covers both, mirroring the
// legacy `switch_br`'s `asInt orelse asTypeId().index()`).
.switch_br => |sb| {
const operand: i64 = @bitCast(frame.get(sb.operand.index()));
for (sb.cases) |case| {
if (operand == case.value) return .{ .jump = .{ .target = case.target, .args = case.args } };
}
return .{ .jump = .{ .target = sb.default, .args = sb.default_args } };
},
.ret => |u| return .{ .ret = frame.get(u.operand.index()) },
.ret_void => return .ret_void,
// T → Any: a 16-byte box `{ type_tag: i64 @0, value: i64 @8 }` (the LLVM
// layout). The tag is the source TypeId index (matches the legacy comptime
// interp; runtime `anyTag` additionally normalizes arbitrary-width ints —
// an existing legacy/runtime split). The value slot holds a word source's
// scalar bytes, or an aggregate source's comptime ADDR (the runtime
// "pointer in the value slot" shape — see emit_llvm.coerceToI64's struct path).
.box_any => |ba| {
const table = try self.requireTable();
const sz = table.typeSizeBytes(.any); // 16
const addr = self.machine.allocBytes(sz, table.typeAlignBytes(.any));
@memset(try self.machine.bytes(addr, sz), 0);
try self.machine.writeWord(addr, 8, @as(Reg, ba.source_type.index()));
const v = frame.get(ba.operand.index());
switch (kindOf(table, ba.source_type)) {
.word => try self.writeField(table, addr + 8, ba.source_type, v),
.aggregate => try self.machine.writeWord(addr + 8, 8, v),
.unsupported => return self.failMsg("comptime VM: box_any of an unsupported source type (any/void/noreturn)"),
}
return .{ .value = addr };
},
// Any → T: read the value slot (offset 8). A word target reads its scalar
// bytes back; an aggregate target reads the stored ADDR (the boxed pointer).
.unbox_any => |ua| {
const table = try self.requireTable();
const base = frame.get(ua.operand.index()); // Addr of the {tag, value} box
switch (kindOf(table, ins.ty)) {
.word => return .{ .value = try self.readField(table, base + 8, ins.ty) },
.aggregate => return .{ .value = try self.machine.readWord(base + 8, 8) },
.unsupported => return self.failMsg("comptime VM: unbox_any to an unsupported target type"),
}
},
// Comptime metatype `#builtin`s (`declare`/`define`). The VM-native
// mirror of the legacy `execBuiltin` arms; an unmodeled builtin returns
// null → bail with its name → legacy fallback (dual-path parity).
.call_builtin => |bi| {
if (try self.callBuiltinVm(bi, ins.ty, frame, ref_types)) |r| return .{ .value = r };
self.detail = @tagName(bi.builtin);
return error.Unsupported;
},
// Not yet ported (memory, aggregates, calls, …): bail loudly with the
// op name — never a silent default.
else => {
self.detail = @tagName(ins.op);
return error.Unsupported;
},
}
}
/// 64-bit integer (wrapping/signed) or f64 arithmetic, keyed on the result
/// type — mirrors the legacy `evalArith`.
fn arith(tag: OpTag, ty: TypeId, l: Reg, r: Reg) Error!Reg {
if (isFloat(ty)) {
const lf: f64 = @bitCast(l);
const rf: f64 = @bitCast(r);
const res: f64 = switch (tag) {
.add => lf + rf,
.sub => lf - rf,
.mul => lf * rf,
.div => if (rf == 0.0) return error.DivisionByZero else lf / rf,
.mod => @mod(lf, rf),
else => unreachable,
};
return @bitCast(res);
}
const li: i64 = @bitCast(l);
const ri: i64 = @bitCast(r);
const res: i64 = switch (tag) {
.add => li +% ri,
.sub => li -% ri,
.mul => li *% ri,
.div => if (ri == 0) return error.DivisionByZero else @divTrunc(li, ri),
.mod => if (ri == 0) return error.DivisionByZero else @mod(li, ri),
else => unreachable,
};
return @bitCast(res);
}
/// 64-bit bitwise AND/OR/XOR and shifts — mirrors the legacy interp's i64
/// model exactly: shifts clamp the amount to `@min(rhs, 63)` and `shr` is an
/// ARITHMETIC right shift (signed `>>`, sign-extending), matching the legacy
/// `.int` representation.
fn bitwise(tag: OpTag, l: Reg, r: Reg) Reg {
const li: i64 = @bitCast(l);
const ri: i64 = @bitCast(r);
const res: i64 = switch (tag) {
.bit_and => li & ri,
.bit_or => li | ri,
.bit_xor => li ^ ri,
.shl => li << @as(u6, @intCast(@min(ri, 63))),
.shr => li >> @as(u6, @intCast(@min(ri, 63))),
else => unreachable,
};
return @bitCast(res);
}
/// Comparison keyed on the operand type: f64 for floats, == / != only for
/// bool, else signed i64 — mirrors the legacy `evalCmp`.
fn cmp(self: *Vm, tag: OpTag, lty: TypeId, l: Reg, r: Reg) Error!bool {
if (isFloat(lty)) {
const lf: f64 = @bitCast(l);
const rf: f64 = @bitCast(r);
return switch (tag) {
.cmp_eq => lf == rf,
.cmp_ne => lf != rf,
.cmp_lt => lf < rf,
.cmp_le => lf <= rf,
.cmp_gt => lf > rf,
.cmp_ge => lf >= rf,
else => unreachable,
};
}
if (lty == .bool) {
const lb = l != 0;
const rb = r != 0;
return switch (tag) {
.cmp_eq => lb == rb,
.cmp_ne => lb != rb,
else => {
self.detail = "comptime VM: bool comparison supports only == / !=";
return error.TypeError;
},
};
}
const li: i64 = @bitCast(l);
const ri: i64 = @bitCast(r);
return switch (tag) {
.cmp_eq => li == ri,
.cmp_ne => li != ri,
.cmp_lt => li < ri,
.cmp_le => li <= ri,
.cmp_gt => li > ri,
.cmp_ge => li >= ri,
else => unreachable,
};
}
fn requireTable(self: *Vm) Error!*const types.TypeTable {
return self.table orelse {
self.detail = "comptime VM: memory/aggregate op needs a type table (not provided)";
return error.Unsupported;
};
}
fn failMsg(self: *Vm, msg: []const u8) error{Unsupported} {
self.detail = msg;
return error.Unsupported;
}
/// Like `failMsg` but for a runtime-formatted reason (e.g. naming the offending
/// variant). Allocated in `gpa` so it survives to the host's diagnostic render;
/// the build fails on this path, so the small leak is moot.
fn failFmt(self: *Vm, comptime fmt: []const u8, args: anytype) error{Unsupported} {
self.detail = std.fmt.allocPrint(self.gpa, fmt, args) catch "comptime VM: out of memory formatting diagnostic";
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;
}
/// The IR type of operand `r`, bounds-checked. Lowering-time IR can carry an
/// out-of-range / `Ref.none` operand (an unresolved name lowers to a dangling
/// ref); reading `ref_types` raw would panic, so bail instead — the host then
/// falls back to the legacy interpreter. The companion to `Frame.get`'s
/// `bad_ref` guard (which covers the value side; this covers the type side).
fn refTy(self: *Vm, ref_types: []const TypeId, r: Ref) Error!TypeId {
if (r.index() >= ref_types.len) return self.badRef();
return ref_types[r.index()];
}
/// 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 comptime memory (no copy).
fn invoke(self: *Vm, fid: inst_mod.FuncId, args: []const Ref, frame: *Frame, ref_types: []const TypeId, result_ty: TypeId) 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 comptime
// 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 comptime memory (Phase 3
// seed). The `compiler_welded` flag is the safety boundary.
if (callee.compiler_welded) {
if (try self.callCompilerFn(name, args, frame, ref_types, result_ty)) |r| return r;
}
// General host-FFI escape: any other extern resolves via dlsym and is
// dispatched through the host_ffi trampolines. Because `Addr` is a real
// host pointer, args pass as `usize` untouched (a scalar's bits OR a
// pointer) and a pointer return comes back as a valid `Addr` — no
// translation. Aggregate/float args+returns aren't marshaled yet (4D.2).
return self.callHostExtern(callee, name, args, frame, ref_types);
}
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);
}
/// Call a real extern (libc / host) function via dlsym + the `host_ffi`
/// trampolines — the comptime VM's host-FFI escape (the legacy `interp.callExtern`
/// equivalent). Marshalling is trivial here because `Addr` is already a host
/// pointer: every WORD-kind arg (scalar OR pointer) passes as `usize` verbatim,
/// and a pointer return is a valid `Addr`. Non-word (aggregate/string/float)
/// args+returns bail loudly (4D.2 adds them) — never a silent miscall.
fn callHostExtern(self: *Vm, callee: *const Function, name: []const u8, args: []const Ref, frame: *Frame, ref_types: []const TypeId) Error!Reg {
const table = try self.requireTable();
if (args.len > 8) return self.failMsg("comptime extern call: more than 8 args (host_ffi trampolines max out at 8)");
const symbol = (host_ffi.lookupSymbol(self.gpa, name) catch return self.failMsg("comptime extern call: dlsym error looking up symbol")) orelse
return self.failMsg("comptime extern call: symbol not found via dlsym (target-specific binding called at compile time?)");
var packed_args: [8]usize = undefined;
for (args, 0..) |a, i| {
packed_args[i] = try self.marshalExternArg(table, try self.refTy(ref_types, a), frame.get(a.index()));
}
const argv = packed_args[0..args.len];
const fixed = callee.params.len;
const variadic = callee.is_variadic and args.len > fixed;
const ret = callee.ret;
if (isFloat(ret))
return self.failMsg("comptime extern call: float return not supported (host_ffi has no float trampoline)");
if (ret == .void or ret == .noreturn) {
if (variadic)
host_ffi.callVoidRetVar(symbol, fixed, argv) catch return self.failMsg("comptime extern call failed (void)")
else
host_ffi.callVoidRet(symbol, argv) catch return self.failMsg("comptime extern call failed (void)");
return @as(Reg, 0);
}
// The C function returns a single register word. For a plain word return
// that word IS the result. For an OPTIONAL whose child is itself a single
// word (e.g. `getenv() -> ?cstring`, a `char*` the sx side treats as a
// nullable handle), the C returns the bare payload and we wrap it into the
// `{payload@0, has@sizeof(child)}` aggregate below (present iff non-null) —
// mirroring emit_llvm's wrapping of an extern `char*`→`?cstring` return.
const opt_child: ?TypeId = if (!ret.isBuiltin() and table.get(ret) == .optional) blk: {
const ch = table.get(ret).optional.child;
// An optional with a SENTINEL (pointer) child is itself a word and is
// handled by the plain-word path; only the `{payload, has}` aggregate
// form (kindOf == .aggregate) needs wrapping here.
break :blk if (kindOf(table, ret) == .aggregate and kindOf(table, ch) == .word and !isFloat(ch)) ch else null;
} else null;
const word_ty: TypeId = opt_child orelse ret;
if (kindOf(table, word_ty) != .word or isFloat(word_ty))
return self.failFmt("comptime extern call '{s}': non-word (aggregate/string/float) return ({s}) not yet supported on the VM", .{ name, table.typeName(ret) });
// A pointer-ish return goes through callPtrRet (void* ABI); an integer-ish
// return through callIntRet (i64 ABI). Either way the result is a single
// word — a returned pointer is already a valid absolute `Addr`.
const r: u64 = if (isPointerish(table, word_ty)) blk: {
break :blk if (variadic)
host_ffi.callPtrRetVar(symbol, fixed, argv) catch return self.failMsg("comptime extern call failed (ptr)")
else
host_ffi.callPtrRet(symbol, argv) catch return self.failMsg("comptime extern call failed (ptr)");
} else blk: {
const v = if (variadic)
host_ffi.callIntRetVar(symbol, fixed, argv) catch return self.failMsg("comptime extern call failed (int)")
else
host_ffi.callIntRet(symbol, argv) catch return self.failMsg("comptime extern call failed (int)");
break :blk @bitCast(v);
};
if (opt_child) |child| {
// Wrap the bare payload word into the `{payload, has}` optional aggregate.
const addr = self.machine.allocBytes(table.typeSizeBytes(ret), table.typeAlignBytes(ret));
try self.writeField(table, addr, child, r);
try self.machine.writeWord(addr + table.typeSizeBytes(child), 1, @intFromBool(r != 0));
return @as(Reg, addr);
}
return @as(Reg, r);
}
/// Marshal one extern arg (of IR type `aty`, register value `reg`) to the `usize`
/// the host_ffi trampolines expect. A scalar/pointer WORD passes verbatim (a
/// pointer Reg is already a host pointer). A string/slice fat-pointer is copied
/// into a NUL-terminated buffer and its `char*` passed (mirrors the legacy
/// `marshalExternArg`). Floats (no float trampoline) and non-fat-pointer
/// aggregates bail loudly — never a silent miscall.
fn marshalExternArg(self: *Vm, table: *const types.TypeTable, aty: TypeId, reg: Reg) Error!usize {
switch (kindOf(table, aty)) {
.word => {
if (isFloat(aty))
return self.failMsg("comptime extern call: float arg not supported (host_ffi has no float trampoline)");
return @intCast(reg); // scalar bits OR host pointer
},
.aggregate => {
// Only a string/slice `{ptr, len}` fat pointer marshals (→ a
// NUL-terminated `char*`); any other aggregate bails.
if (aty != .string and (aty.isBuiltin() or table.get(aty) != .slice))
return self.failMsg("comptime extern call: non-string/slice aggregate arg not marshaled on the VM");
const n: usize = @intCast(try self.sliceLen(reg));
const data = try self.sliceData(table, reg);
const buf = self.machine.allocBytes(n + 1, 1); // zeroed → NUL at [n]
if (n > 0) @memcpy(try self.machine.bytes(buf, n), try self.machine.bytes(data, n));
return @intCast(buf);
},
.unsupported => return self.failMsg("comptime extern call: unsupported arg type"),
}
}
/// 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 comptime 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 (comptime arena 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 comptime 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 comptime 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`.
/// Read compiler-call arg `i` as a u32 handle (a `StringId` / `TypeId` word),
/// range-checked — never a silent truncation.
fn argHandle(self: *Vm, args: []const Ref, frame: *Frame, i: usize) Error!u32 {
const raw = frame.get(args[i].index());
if (raw > std.math.maxInt(u32)) return self.failMsg("comptime compiler call: handle arg out of u32 range");
return @intCast(raw);
}
/// Read compiler-call arg `i` as a `TypeId` handle.
fn argTypeId(self: *Vm, args: []const Ref, frame: *Frame, i: usize) Error!TypeId {
return @enumFromInt(try self.argHandle(args, frame, i));
}
fn callCompilerFn(self: *Vm, name: []const u8, args: []const Ref, frame: *Frame, ref_types: []const TypeId, result_ty: TypeId) 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)));
return try self.makeStringValue(table, table.getString(id));
}
// ── read-only reflection readers (Phase 3) ──────────────────────────
// Type handle = a u32 `TypeId` (a word), exactly like `StringId` — so
// these mirror intern/text_of's shape: word in, word out, no marshaling.
if (std.mem.eql(u8, name, "find_type")) {
if (args.len != 1) return self.failMsg("comptime find_type: expected one StringId arg");
const sid: types.StringId = @enumFromInt(try self.argHandle(args, frame, 0));
// Not found → the dedicated `unresolved` (0) sentinel, never a real
// type id (mirrors `compiler_lib.handleFindType`).
const tid = table.findByName(sid) orelse TypeId.unresolved;
return @as(Reg, tid.index());
}
if (std.mem.eql(u8, name, "type_field_count")) {
if (args.len != 1) return self.failMsg("comptime type_field_count: expected one TypeId arg");
const tid = try self.argTypeId(args, frame, 0);
// Same `TypeTable.memberCount` the legacy handler reads → no drift; a
// type with no member count bails loudly (no silent 0).
const count = table.memberCount(tid) orelse
return self.failMsg("comptime type_field_count: type has no field/variant count");
return @as(Reg, @bitCast(count));
}
if (std.mem.eql(u8, name, "type_nominal_name")) {
if (args.len != 1) return self.failMsg("comptime type_nominal_name: expected one TypeId arg");
const tid = try self.argTypeId(args, frame, 0);
const sid = table.nominalName(tid) orelse
return self.failMsg("comptime type_nominal_name: type has no nominal name");
return @as(Reg, @intFromEnum(sid));
}
if (std.mem.eql(u8, name, "type_field_name")) {
if (args.len != 2) return self.failMsg("comptime type_field_name: expected (TypeId, idx)");
const tid = try self.argTypeId(args, frame, 0);
const idx: i64 = @bitCast(frame.get(args[1].index()));
const sid = table.memberName(tid, idx) orelse
return self.failMsg("comptime type_field_name: out-of-range idx or unnamed member");
return @as(Reg, @intFromEnum(sid));
}
if (std.mem.eql(u8, name, "type_field_type")) {
if (args.len != 2) return self.failMsg("comptime type_field_type: expected (TypeId, idx)");
const tid = try self.argTypeId(args, frame, 0);
const idx: i64 = @bitCast(frame.get(args[1].index()));
const mty = table.memberType(tid, idx) orelse
return self.failMsg("comptime type_field_type: out-of-range idx or member has no type");
return @as(Reg, mty.index());
}
if (std.mem.eql(u8, name, "type_kind")) {
if (args.len != 1) return self.failMsg("comptime type_kind: expected one TypeId arg");
const tid = try self.argTypeId(args, frame, 0);
return @as(Reg, @bitCast(table.kindCode(tid))); // total — never bails
}
if (std.mem.eql(u8, name, "type_field_value")) {
if (args.len != 2) return self.failMsg("comptime type_field_value: expected (TypeId, idx)");
const tid = try self.argTypeId(args, frame, 0);
const idx: i64 = @bitCast(frame.get(args[1].index()));
const v = table.memberValue(tid, idx) orelse
return self.failMsg("comptime type_field_value: non-enum or out-of-range idx");
return @as(Reg, @bitCast(v));
}
// ── write side (lowering-time, mints into the type table) ───────────
// These MINT into the type table via `@constCast(table)` — the same
// mutable access the read-side `intern` uses (the table is genuinely
// mutable; the VM merely holds it `const`). They take/return real `Type`
// values (`.type_value` words = `TypeId.index()`). Mirror the legacy
// `compiler_lib` handlers exactly so the dual paths can't drift.
if (std.mem.eql(u8, name, "declare_type")) {
if (args.len != 1) return self.failMsg("comptime declare_type: expected (name)");
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)));
return @as(Reg, (self.declareNominal(table, text)).index());
}
if (std.mem.eql(u8, name, "pointer_to")) {
if (args.len != 1) return self.failMsg("comptime pointer_to: expected (Type)");
const t = try self.argTypeId(args, frame, 0);
return @as(Reg, @constCast(table).intern(.{ .pointer = .{ .pointee = t } }).index());
}
if (std.mem.eql(u8, name, "register_type")) {
return self.registerTypeVm(args, frame, ref_types);
}
// ── BuildOptions (migrated off `#compiler` onto `abi(.compiler)`) ───────
// `build_options()` hands back an opaque, zero-field `BuildOptions` handle;
// the real state lives on the threaded `BuildConfig`. Return the null
// sentinel word (the handle is never dereferenced — every operation takes it
// as an ignored `self`). Mirrors the legacy `hookBuildOptions` (`.void_val`).
if (std.mem.eql(u8, name, "build_options")) {
return @as(Reg, null_addr);
}
// `on_build(cb)` — register the build callback (the Phase 5 form, `cb:
// (opt: BuildOptions) -> bool`). Like `set_post_link_callback` but a free
// fn (cb is arg 0, no self) and the callback receives the `BuildOptions`
// handle when invoked (the `post_link_takes_options` flag drives that).
if (std.mem.eql(u8, name, "on_build")) {
if (args.len != 1) return self.failMsg("comptime on_build: expected (cb)");
const bc = self.build_config orelse
return self.failMsg("comptime on_build: no build config threaded into the VM");
const fid = funcRefToId(frame.get(args[0].index())) orelse
return self.failMsg("comptime on_build: cb arg is not a function value");
bc.post_link_callback_fn = fid;
bc.post_link_takes_options = true;
return @as(Reg, null_addr);
}
// ── build-pipeline metadata queries (Phase 5.2) ─────────────────────
// Read-only: the compiler answers them from the `BuildConfig` `main.zig`
// forwards before the post-link callback runs. Each builds a fresh
// `List(string)` in comptime memory (the result type drives its layout) — no
// driver action, so they're pure data even in the sx-driven end state.
if (std.mem.eql(u8, name, "c_object_paths")) {
if (args.len != 0) return self.failMsg("comptime c_object_paths: expected no args");
const bc = self.build_config orelse
return self.failMsg("comptime c_object_paths: no build config threaded into the VM");
return try self.makeStringList(table, result_ty, bc.c_object_paths);
}
if (std.mem.eql(u8, name, "link_libraries")) {
if (args.len != 0) return self.failMsg("comptime link_libraries: expected no args");
const bc = self.build_config orelse
return self.failMsg("comptime link_libraries: no build config threaded into the VM");
return try self.makeStringList(table, result_ty, bc.link_libraries);
}
// `emit_object() -> string` — ACTION: verify + emit the codegen'd module
// to its object file and return the path. Dispatches through the
// host-installed hook (the VM can't emit itself); the driver no longer
// auto-emits (everything is sx-driven via `default_pipeline`).
if (std.mem.eql(u8, name, "emit_object")) {
if (args.len != 0) return self.failMsg("comptime emit_object: expected no args");
const bc = self.build_config orelse
return self.failMsg("comptime emit_object: no build config threaded into the VM");
const hooks = bc.build_hooks orelse
return self.failMsg("comptime emit_object: no build hooks installed (emit is a post-codegen-only action)");
const path = hooks.emit_object(hooks.ctx) catch
return self.failMsg("comptime emit_object: object emission failed");
return try self.makeStringValue(table, path);
}
// Build-config metadata the sx driver passes to `link`. Read-only data
// forwarded by `main.zig` (the merged CLI + `#run` build config).
if (std.mem.eql(u8, name, "build_output")) {
if (args.len != 0) return self.failMsg("comptime build_output: expected no args");
const bc = self.build_config orelse return self.failMsg("comptime build_output: no build config");
return try self.makeStringValue(table, bc.output_path orelse "");
}
if (std.mem.eql(u8, name, "build_target")) {
if (args.len != 0) return self.failMsg("comptime build_target: expected no args");
const bc = self.build_config orelse return self.failMsg("comptime build_target: no build config");
return try self.makeStringValue(table, bc.target_triple orelse "");
}
if (std.mem.eql(u8, name, "build_frameworks")) {
if (args.len != 0) return self.failMsg("comptime build_frameworks: expected no args");
const bc = self.build_config orelse return self.failMsg("comptime build_frameworks: no build config");
return try self.makeStringList(table, result_ty, bc.target_frameworks);
}
if (std.mem.eql(u8, name, "build_flags")) {
if (args.len != 0) return self.failMsg("comptime build_flags: expected no args");
const bc = self.build_config orelse return self.failMsg("comptime build_flags: no build config");
return try self.makeStringList(table, result_ty, bc.merged_link_flags);
}
// `link(objects, output, libraries, frameworks, flags, target)` — the one
// genuine ACTION: dispatch to the host-installed linker (the VM can't link
// itself). Void return (the build callback isn't fallible — Phase 5
// decision); a link failure bails loudly → hard build error. `ref_types`
// gives each List(string) arg its concrete type for the comptime reader.
if (std.mem.eql(u8, name, "link")) {
if (args.len != 6) return self.failMsg("comptime link: expected (objects, output, libraries, frameworks, flags, target)");
const bc = self.build_config orelse
return self.failMsg("comptime link: no build config threaded into the VM");
const hooks = bc.build_hooks orelse
return self.failMsg("comptime link: no build hooks installed (link is a post-codegen-only action)");
const objects = try self.readStringList(table, ref_types[args[0].index()], frame.get(args[0].index()));
const output = try self.readStringArg(table, frame.get(args[1].index()));
const libraries = try self.readStringList(table, ref_types[args[2].index()], frame.get(args[2].index()));
const frameworks = try self.readStringList(table, ref_types[args[3].index()], frame.get(args[3].index()));
const flags = try self.readStringList(table, ref_types[args[4].index()], frame.get(args[4].index()));
const target_str = try self.readStringArg(table, frame.get(args[5].index()));
hooks.link(hooks.ctx, objects, output, libraries, frameworks, flags, target_str) catch
return self.failMsg("comptime link: linking failed");
return @as(Reg, null_addr); // void
}
// ── BuildOptions accessors (Phase 5.5) ──────────────────────────────
// Migrated off `struct #compiler` hooks onto VM-native arms. `self` (the
// opaque BuildOptions handle) is args[0] and ignored; the real state lives
// on the threaded `BuildConfig`. SETTERS dupe the string arg into the
// PERSISTENT `self.gpa` (the Compilation allocator — NOT the per-eval VM
// arena, whose bytes die at `Vm.deinit`) so it survives to post-link.
if (try self.callBuildOptionFn(name, args, frame)) |r| return r;
return null; // not a known compiler function → caller bails to legacy
}
/// Read string arg `idx` (a `{ptr,len}` fat pointer) and DUPE it into the
/// persistent `self.gpa`. The VM-arena view dies at `Vm.deinit`, so a
/// BuildConfig string set at `#run` must own a persistent copy.
fn dupeArgStr(self: *Vm, args: []const Ref, frame: *Frame, idx: usize) Error![]const u8 {
const table = try self.requireTable();
const view = try self.readStringArg(table, frame.get(args[idx].index()));
return self.gpa.dupe(u8, view) catch return self.failMsg("comptime BuildOptions setter: out of memory");
}
/// VM-native `BuildOptions` accessors (Phase 5.5). Returns null when `name` is
/// not a BuildOptions accessor (the caller then yields null → "unknown").
fn callBuildOptionFn(self: *Vm, name: []const u8, args: []const Ref, frame: *Frame) Error!?Reg {
const table = try self.requireTable();
// A getter/setter on a string field: `name` → the `?[]const u8` field. A
// setter (one extra arg) writes a persistent dupe; a getter returns the
// value (or "" when unset). Both ignore the `self` handle at args[0].
const StrField = struct { set: []const u8, get: []const u8, field: *?[]const u8 };
// A BuildOptions accessor is only ever reached from a `#run` / post-link
// eval, which always threads a `BuildConfig`. A null `bc` here means this
// isn't a BuildOptions call at all (e.g. a lowering-time type-fn) — yield
// null so the caller treats it as unknown (it then bails loudly).
const bc = self.build_config orelse return null;
const str_fields = [_]StrField{
.{ .set = "set_output_path", .get = "", .field = &bc.output_path },
.{ .set = "set_wasm_shell", .get = "", .field = &bc.wasm_shell_path },
.{ .set = "set_post_link_module", .get = "", .field = &bc.post_link_module },
.{ .set = "set_bundle_path", .get = "bundle_path", .field = &bc.bundle_path },
.{ .set = "set_bundle_id", .get = "bundle_id", .field = &bc.bundle_id },
.{ .set = "set_codesign_identity", .get = "codesign_identity", .field = &bc.codesign_identity },
.{ .set = "set_provisioning_profile", .get = "provisioning_profile", .field = &bc.provisioning_profile },
.{ .set = "set_manifest_path", .get = "manifest_path", .field = &bc.manifest_path },
.{ .set = "set_keystore_path", .get = "keystore_path", .field = &bc.keystore_path },
.{ .set = "_", .get = "binary_path", .field = &bc.binary_path },
.{ .set = "_", .get = "target_triple", .field = &bc.target_triple },
};
for (str_fields) |sf| {
if (sf.set.len > 1 and std.mem.eql(u8, name, sf.set)) {
if (args.len != 2) return self.failMsg("comptime BuildOptions setter: expected (self, value)");
sf.field.* = try self.dupeArgStr(args, frame, 1);
return @as(Reg, null_addr);
}
if (sf.get.len > 0 and std.mem.eql(u8, name, sf.get)) {
if (args.len != 1) return self.failMsg("comptime BuildOptions getter: expected (self)");
return try self.makeStringValue(table, sf.field.* orelse "");
}
}
// List-appending setters (dupe + append into the persistent gpa).
if (std.mem.eql(u8, name, "add_link_flag")) {
if (args.len != 2) return self.failMsg("comptime add_link_flag: expected (self, flag)");
bc.link_flags.append(self.gpa, try self.dupeArgStr(args, frame, 1)) catch
return self.failMsg("comptime add_link_flag: out of memory");
return @as(Reg, null_addr);
}
if (std.mem.eql(u8, name, "add_framework")) {
if (args.len != 2) return self.failMsg("comptime add_framework: expected (self, name)");
bc.frameworks.append(self.gpa, try self.dupeArgStr(args, frame, 1)) catch
return self.failMsg("comptime add_framework: out of memory");
return @as(Reg, null_addr);
}
if (std.mem.eql(u8, name, "add_asset_dir")) {
if (args.len != 3) return self.failMsg("comptime add_asset_dir: expected (self, src, dest)");
const src = try self.dupeArgStr(args, frame, 1);
const dest = try self.dupeArgStr(args, frame, 2);
bc.asset_dirs.append(self.gpa, .{ .src = src, .dest = dest }) catch
return self.failMsg("comptime add_asset_dir: out of memory");
return @as(Reg, null_addr);
}
// Count getters (i64).
if (std.mem.eql(u8, name, "asset_dir_count"))
return @as(Reg, @bitCast(@as(i64, @intCast(bc.asset_dirs.items.len))));
if (std.mem.eql(u8, name, "framework_count"))
return @as(Reg, @bitCast(@as(i64, @intCast(bc.target_frameworks.len))));
if (std.mem.eql(u8, name, "framework_path_count"))
return @as(Reg, @bitCast(@as(i64, @intCast(bc.target_framework_paths.len))));
if (std.mem.eql(u8, name, "jni_main_count"))
return @as(Reg, @bitCast(@as(i64, @intCast(bc.jni_main_runtime_paths.len))));
// Indexed string getters (out-of-range → "", mirroring the legacy hooks).
// Asset dirs are `{src,dest}` structs, so read the field directly.
if (std.mem.eql(u8, name, "asset_dir_src_at") or std.mem.eql(u8, name, "asset_dir_dest_at")) {
if (args.len != 2) return self.failMsg("comptime asset_dir getter: expected (self, i)");
const idx: i64 = @bitCast(frame.get(args[1].index()));
if (idx < 0 or @as(usize, @intCast(idx)) >= bc.asset_dirs.items.len)
return try self.makeStringValue(table, "");
const ad = bc.asset_dirs.items[@intCast(idx)];
return try self.makeStringValue(table, if (name[10] == 's') ad.src else ad.dest);
}
if (std.mem.eql(u8, name, "framework_at"))
return try self.indexedStr(args, frame, bc.target_frameworks);
if (std.mem.eql(u8, name, "framework_path_at"))
return try self.indexedStr(args, frame, bc.target_framework_paths);
if (std.mem.eql(u8, name, "jni_main_runtime_path_at"))
return try self.indexedStr(args, frame, bc.jni_main_runtime_paths);
if (std.mem.eql(u8, name, "jni_main_java_source_at"))
return try self.indexedStr(args, frame, bc.jni_main_java_sources);
// Target predicates (computed from the triple — mirror the legacy hooks).
if (boolPredicate(name)) |pred| {
if (args.len != 1) return self.failMsg("comptime BuildOptions predicate: expected (self)");
return @as(Reg, if (pred(bc.target_triple)) 1 else 0);
}
return null; // not a BuildOptions accessor
}
/// Read index arg 1, bounds-check against `items`, and return the element
/// string (or "" when out of range — mirrors the legacy hook behavior).
fn indexedStr(self: *Vm, args: []const Ref, frame: *Frame, items: []const []const u8) Error!Reg {
const table = try self.requireTable();
if (args.len != 2) return self.failMsg("comptime BuildOptions indexed getter: expected (self, i)");
const idx: i64 = @bitCast(frame.get(args[1].index()));
if (idx < 0 or @as(usize, @intCast(idx)) >= items.len)
return try self.makeStringValue(table, "");
return try self.makeStringValue(table, items[@intCast(idx)]);
}
/// VM-native `register_type(handle: Type, kind: i64, members: []Member) -> Type`
/// — fill a `declare_type`'d forward slot, branching on `kind` in the compiler
/// (mirrors `compiler_lib.handleRegisterType`, but reads `[]Member` from comptime
/// memory instead of decoding a `Value`). `Member` is `{ name: string, ty: Type }`.
fn registerTypeVm(self: *Vm, args: []const Ref, frame: *Frame, ref_types: []const TypeId) Error!?Reg {
const table = try self.requireTable();
if (args.len != 3) return self.failMsg("comptime register_type: expected (handle, kind, members)");
const handle = try self.argTypeId(args, frame, 0);
const kind: i64 = @bitCast(frame.get(args[1].index()));
// Decode the `[]Member` slice (element layout `{ name: string, ty: Type }`).
const slice_ty = try self.refTy(ref_types, args[2]);
const members_word = frame.get(args[2].index());
var members = std.ArrayList(NamedMember).empty;
defer members.deinit(self.gpa);
try self.decodeMemberSlice(table, members_word, slice_ty, &members);
// A comptime-constructed type with NO members is VALID for every kind
// (empty struct / tuple / enum / tagged_union). The per-kind loops below
// are vacuous for an empty member list and the dup-name checks stay
// correct. The completion always sets `defined = true`, so the result is
// distinguishable from a never-completed `declare(...)` placeholder
// (which carries `defined = false`).
const tbl = @constCast(table);
// The slot's nominal identity — accept the forward `tagged_union` from
// `declare_type` AND an already-completed nominal of the same name (so a
// re-fill via two import edges is idempotent). A non-nominal handle (not a
// `declare_type`'d slot) is rejected.
const ident = nominalIdentOf(table.get(handle)) orelse
return self.failMsg("comptime register_type: handle is not a declare_type'd nominal slot");
switch (kind) {
4 => { // tuple — positional element types (names ignored)
const tys = self.gpa.alloc(TypeId, members.items.len) catch return self.failMsg("comptime register_type: out of memory");
for (members.items, 0..) |m, i| tys[i] = m.ty;
tbl.replaceKeyedInfo(handle, .{ .tuple = .{ .fields = tys, .names = null } });
},
2 => { // actual (payloadless) enum — members are variant NAMES; payload must be void
const names = self.gpa.alloc(types.StringId, members.items.len) catch return self.failMsg("comptime register_type: out of memory");
for (members.items, 0..) |m, i| {
if (m.ty != .void) return self.failMsg("comptime register_type: payload variant — use kind 3 (tagged_union)");
for (names[0..i]) |prev| if (prev == m.name) return self.failFmt("comptime register_type: duplicate variant name '{s}'", .{tbl.getString(m.name)});
names[i] = m.name;
}
tbl.replaceKeyedInfo(handle, .{ .@"enum" = .{ .name = ident.name, .variants = names, .nominal_id = ident.nominal_id } });
},
1, 3 => { // struct / tagged_union — `{ name, ty }` fields (dup names rejected)
const flds = self.gpa.alloc(types.TypeInfo.StructInfo.Field, members.items.len) catch return self.failMsg("comptime register_type: out of memory");
for (members.items, 0..) |m, i| {
for (flds[0..i]) |prev| if (prev.name == m.name) return self.failFmt("comptime register_type: duplicate member name '{s}'", .{tbl.getString(m.name)});
flds[i] = .{ .name = m.name, .ty = m.ty };
}
const full: types.TypeInfo = if (kind == 1)
.{ .@"struct" = .{ .name = ident.name, .fields = flds, .nominal_id = ident.nominal_id } }
else
.{ .tagged_union = .{ .name = ident.name, .fields = flds, .tag_type = .i64, .nominal_id = ident.nominal_id } };
tbl.replaceKeyedInfo(handle, full);
},
else => return self.failMsg("comptime register_type: unknown kind code"),
}
return @as(Reg, handle.index());
}
/// Mint (or find) a forward `declare`'d nominal slot named `text`: an empty
/// `tagged_union` placeholder a later `define`/`register_type` completes in
/// place. Idempotent — lowering already registered the named forward slot (so a
/// `*Name` self-reference in the body resolved), so return THAT slot. Shared by
/// the compiler-API `declare_type` and the metatype `declare` builtin.
fn declareNominal(self: *Vm, table: *const types.TypeTable, text: []const u8) TypeId {
_ = self;
const tbl = @constCast(table);
const name_id = tbl.internString(text);
if (tbl.findByName(name_id)) |existing| return existing;
return tbl.internNominal(.{ .tagged_union = .{ .name = name_id, .fields = &.{}, .tag_type = .i64, .defined = false } }, 0);
}
/// Decode a `[]{ name: string, ty: Type }` slice from comptime memory into interned
/// `(StringId, TypeId)` pairs — the shared shape of a compiler-API `Member`, a
/// metatype `EnumVariant { name, payload }`, and a `StructField { name, type }`.
/// `slice_ty` (the slice's IR type) gives the element layout (field offsets +
/// stride). Every malformed shape bails loudly (no silent default).
fn decodeMemberSlice(self: *Vm, table: *const types.TypeTable, slice_word: Reg, slice_ty: TypeId, out: *std.ArrayList(NamedMember)) Error!void {
if (slice_ty.isBuiltin() or table.get(slice_ty) != .slice)
return self.failMsg("comptime define/register: members arg is not a slice");
const member_ty = table.get(slice_ty).slice.element;
if (member_ty.isBuiltin() or table.get(member_ty) != .@"struct" or table.get(member_ty).@"struct".fields.len != 2)
return self.failMsg("comptime define/register: member element must be a {name, ty} struct");
const mfields = table.get(member_ty).@"struct".fields;
const name_off = fieldOffset(table, member_ty, 0);
const ty_off = fieldOffset(table, member_ty, 1);
const name_fty = mfields[0].ty; // string
const len = try self.sliceLen(slice_word);
const base = try self.sliceData(table, slice_word);
const stride: Addr = @intCast(table.typeSizeBytes(member_ty));
const tbl = @constCast(table);
for (0..@intCast(len)) |i| {
const elem = base + @as(Addr, @intCast(i)) * stride;
const name_fp = try self.readField(table, elem + name_off, name_fty); // string fat-pointer Addr
const mname = try self.machine.bytes(try self.sliceData(table, name_fp), @intCast(try self.sliceLen(name_fp)));
const mty: TypeId = @enumFromInt(@as(u32, @intCast(try self.readField(table, elem + ty_off, .type_value))));
out.append(self.gpa, .{ .name = tbl.internString(mname), .ty = mty }) catch return self.failMsg("comptime define/register: out of memory");
}
}
/// Resolve the `TypeId` a reflection builtin (`type_name` / `type_is_unsigned`)
/// queries, given the arg's IR type `aty` and its register word `w`. A
/// `.type_value` word IS a `TypeId`; an Any box `{ tag@0, value@8 }` yields its
/// tag (the boxed value's runtime type), unless tag == `type_value` — a boxed
/// Type (the `type_of(x)` shape) whose real id sits in the value slot. The
/// VM-native mirror of the legacy `Value.reflectTypeId`.
fn reflectArgTypeId(self: *Vm, aty: TypeId, w: Reg) Error!TypeId {
// A `TypeId` index is a u32; a word that doesn't fit is a garbage/mis-read
// value (e.g. a wrong slice stride yielding an `Any` element at the wrong
// offset — see 0522). Bail loudly instead of letting `@intCast` abort: the
// VM must never crash.
if (aty == .type_value) return TypeId.fromIndex(try self.typeIdxOf(w));
if (aty == .any) {
const tag = try self.machine.readWord(w, 8);
if (tag == @as(u64, TypeId.type_value.index()))
return TypeId.fromIndex(try self.typeIdxOf(try self.machine.readWord(w + 8, 8)));
return TypeId.fromIndex(try self.typeIdxOf(tag));
}
return self.failMsg("comptime reflection builtin: arg is not a Type value or an Any box");
}
/// Narrow a 64-bit word to a `u32` `TypeId` index, bailing (never crashing) when
/// it doesn't fit — the tripwire for a mis-read reflection arg.
fn typeIdxOf(self: *Vm, w: u64) Error!u32 {
return std.math.cast(u32, w) orelse
self.failMsg("comptime reflection builtin: type word out of TypeId range (mis-read arg?)");
}
/// Service a comptime metatype `#builtin` (`meta.sx`'s `declare`/`define`)
/// natively on comptime memory, the VM-native mirror of the legacy
/// `interp.execBuiltinInner` arms. Returns the result word, or `null` for a
/// builtin the VM doesn't model yet (caller bails → legacy fallback, so dual-path
/// parity holds). Keeps BOTH paths alive during the VM-default transition.
fn callBuiltinVm(self: *Vm, bi: inst_mod.BuiltinCall, ins_ty: TypeId, frame: *Frame, ref_types: []const TypeId) Error!?Reg {
switch (bi.builtin) {
// `declare(name)` and `define(handle, info)` are no longer builtins —
// they're plain sx in `modules/std/meta.sx` over the compiler-API
// primitives `declare_type` / `register_type` (`callCompilerFn`). The
// `.declare` / `.define` BuiltinIds and `defineFromInfo` were removed.
// type_name(x) → the type's name as a string. The arg is a Type value
// (`.type_value` word = a TypeId) or an Any box (`{tag@0, value@8}` whose
// tag IS the boxed value's type, unless tag == type_value: then the boxed
// Type's id is in the value slot). Mirrors the legacy `reflectTypeId`.
.type_name => {
const table = try self.requireTable();
if (bi.args.len < 1) return self.failMsg("comptime type_name: missing argument");
const tid = try self.reflectArgTypeId(try self.refTy(ref_types, bi.args[0]), frame.get(bi.args[0].index()));
return try self.makeStringValue(table, table.typeName(tid));
},
// type_is_unsigned(x) → is x's type an unsigned int? Resolves the TypeId
// the same way as type_name (a `.type_value` word, or an Any box whose tag
// IS the boxed value's type), then queries `isUnsignedInt`. Mirrors the
// legacy `type_is_unsigned` builtin (`reflectTypeId` + `isUnsignedInt`).
.type_is_unsigned => {
const table = try self.requireTable();
if (bi.args.len < 1) return self.failMsg("comptime type_is_unsigned: missing argument");
const tid = try self.reflectArgTypeId(try self.refTy(ref_types, bi.args[0]), frame.get(bi.args[0].index()));
return @as(Reg, @intFromBool(table.isUnsignedInt(tid)));
},
// type_info($T) → reflect a type INTO a TypeInfo VALUE (the inverse of
// define's decode). The arg folded to a `const_type` (a `.type_value`
// word = the source TypeId); build the value in comptime memory.
.type_info => {
const table = try self.requireTable();
if (bi.args.len != 1) return self.failMsg("comptime type_info: expected (Type)");
const tid = try self.argTypeId(bi.args, frame, 0);
return try self.buildTypeInfo(table, ins_ty, tid);
},
else => return null, // not modeled on the VM yet → caller bails to legacy
}
}
/// Reflect type `tid` INTO a `TypeInfo` VALUE built in comptime memory — the inverse
/// of the sx `define` (which calls `register_type`). The
/// element/struct layouts come from the `result_ty` (= the metatype `TypeInfo`
/// tagged union): variant tag `t` → payload struct `EnumInfo`/`StructInfo`/
/// `TupleInfo` (one slice field) → the slice element (`EnumVariant`/`StructField`/
/// `Type`). Mirrors the legacy member shapes: a tagged-union/struct field and an
/// enum variant reflect as `{ name, ty }` (a payloadless variant carries `void`);
/// tuple elements are bare positional `Type`s. `define(declare(n), type_info(T))`
/// round-trips to a byte-identical nominal copy.
fn buildTypeInfo(self: *Vm, table: *const types.TypeTable, result_ty: TypeId, tid: TypeId) Error!Reg {
if (result_ty.isBuiltin() or table.get(result_ty) != .tagged_union)
return self.failMsg("comptime type_info: result type is not the TypeInfo tagged union");
const ti = table.get(result_ty).tagged_union;
if (ti.backing_type != null)
return self.failMsg("comptime type_info: TypeInfo result is a backing_type tagged union (unexpected layout)");
if (tid.isBuiltin())
return self.failMsg("comptime type_info: only enum / tagged-union / struct / tuple types reflect");
// Decode the source type into a tag + members. enum/tagged-union/struct share
// the `{ name, ty }` element; tuple uses bare positional `Type`s.
const info = table.get(tid);
var pairs = std.ArrayList(NamedMember).empty;
defer pairs.deinit(self.gpa);
var tup = std.ArrayList(TypeId).empty;
defer tup.deinit(self.gpa);
const tag: u32 = switch (info) {
.tagged_union => |u| blk: {
for (u.fields) |f| pairs.append(self.gpa, .{ .name = f.name, .ty = f.ty }) catch return self.failMsg("comptime type_info: out of memory");
break :blk 0;
},
.@"enum" => |e| blk: {
for (e.variants) |v| pairs.append(self.gpa, .{ .name = v, .ty = .void }) catch return self.failMsg("comptime type_info: out of memory");
break :blk 0;
},
.@"struct" => |s| blk: {
for (s.fields) |f| pairs.append(self.gpa, .{ .name = f.name, .ty = f.ty }) catch return self.failMsg("comptime type_info: out of memory");
break :blk 1;
},
.tuple => |t| blk: {
for (t.fields) |ety| tup.append(self.gpa, ety) catch return self.failMsg("comptime type_info: out of memory");
break :blk 2;
},
else => return self.failMsg("comptime type_info: only enum / tagged-union / struct / tuple types reflect"),
};
const count = if (tag == 2) tup.items.len else pairs.items.len;
if (count == 0) return self.failMsg("comptime type_info: type has no members");
// Layout from the TypeInfo result: payload struct (one slice field) → element.
if (tag >= ti.fields.len) return self.failMsg("comptime type_info: TypeInfo has no variant for this kind");
const payload_ty = ti.fields[tag].ty;
if (payload_ty.isBuiltin() or table.get(payload_ty) != .@"struct" or table.get(payload_ty).@"struct".fields.len != 1)
return self.failMsg("comptime type_info: TypeInfo payload is not a single-slice info struct");
const slice_field_ty = table.get(payload_ty).@"struct".fields[0].ty;
if (slice_field_ty.isBuiltin() or table.get(slice_field_ty) != .slice)
return self.failMsg("comptime type_info: info struct field is not a slice");
const elem_ty = table.get(slice_field_ty).slice.element;
const elem_size: Addr = @intCast(table.typeSizeBytes(elem_ty));
// Build the element array: bare `Type` words for a tuple, else `{ name, ty }`.
const data = self.machine.allocBytes(@intCast(elem_size * @as(Addr, @intCast(count))), table.typeAlignBytes(elem_ty));
if (tag == 2) {
for (tup.items, 0..) |ety, i| try self.writeField(table, data + @as(Addr, @intCast(i)) * elem_size, elem_ty, @as(Reg, ety.index()));
} else {
if (elem_ty.isBuiltin() or table.get(elem_ty) != .@"struct" or table.get(elem_ty).@"struct".fields.len != 2)
return self.failMsg("comptime type_info: member element is not a {name, ty} struct");
const name_fty = table.get(elem_ty).@"struct".fields[0].ty; // string
const name_off = fieldOffset(table, elem_ty, 0);
const ty_off = fieldOffset(table, elem_ty, 1);
for (pairs.items, 0..) |m, i| {
const elem = data + @as(Addr, @intCast(i)) * elem_size;
const name_val = try self.makeStringValue(table, table.getString(m.name));
try self.writeField(table, elem + name_off, name_fty, name_val);
try self.writeField(table, elem + ty_off, .type_value, @as(Reg, m.ty.index()));
}
}
// members slice → info struct { slice } → TypeInfo { tag, info }.
const slice = try self.makeSlice(table, data, @intCast(count));
const pinfo = self.machine.allocBytes(table.typeSizeBytes(payload_ty), table.typeAlignBytes(payload_ty));
@memset(try self.machine.bytes(pinfo, table.typeSizeBytes(payload_ty)), 0);
try self.writeField(table, pinfo + fieldOffset(table, payload_ty, 0), slice_field_ty, slice);
const ti_size = table.typeSizeBytes(result_ty);
const ti_addr = self.machine.allocBytes(ti_size, table.typeAlignBytes(result_ty));
@memset(try self.machine.bytes(ti_addr, ti_size), 0);
try self.writeField(table, ti_addr, ti.tag_type, @as(Reg, tag));
const tag_size: Addr = @intCast(table.typeSizeBytes(ti.tag_type));
try self.writeField(table, ti_addr + tag_size, payload_ty, pinfo);
return @as(Reg, ti_addr);
}
// ── Reg ↔ Value bridge (legacy-interop boundary) ────────────────────────
//
// The wiring step routes a comptime eval through the VM, falling back to the
// legacy `interp.zig` (tagged `Value` model) on `error.Unsupported`. The
// boundary converts host `Value` args → VM `Reg` words and the VM's result back
// → a `Value`. This IS a (de)serialization, but ONLY at the legacy boundary and
// ONLY for the shapes the VM handled — it is transitional, deleted once the VM
// owns comptime end-to-end. Covers scalars + strings + structs; other aggregate
// shapes bail loudly (added as wiring surfaces them).
/// Convert a VM `Reg` (+ comptime memory) of type `ty` back into a legacy `Value`.
/// Strings/aggregates are deep-copied into `alloc` (they must outlive comptime memory).
pub fn regToValue(self: *Vm, alloc: std.mem.Allocator, table: *const types.TypeTable, reg: Reg, ty: TypeId) Error!Value {
switch (kindOf(table, ty)) {
.word => {
if (isFloat(ty)) return .{ .float = @bitCast(reg) };
if (ty == .bool) return .{ .boolean = reg != 0 };
// A `Type` value word is a `TypeId` index → the first-class
// `.type_tag` Value the legacy interp/host uses for Type values.
if (ty == .type_value) return .{ .type_tag = TypeId.fromIndex(@intCast(reg)) };
// 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 = 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);
if (info == .@"struct") {
const out = alloc.alloc(Value, info.@"struct".fields.len) catch return self.failMsg("reg→value: out of memory (struct)");
for (info.@"struct".fields, 0..) |f, i| {
const fr = try self.readField(table, reg + fieldOffset(table, ty, @intCast(i)), f.ty);
out[i] = try self.regToValue(alloc, table, fr, f.ty);
}
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"),
}
}
/// How a value of type `ty` is held: a register word (scalar/pointer, ≤8
/// bytes) or by-address in comptime memory (struct). Anything else is not ported
/// yet (slice/string/any/optional/enum/union/array/tuple/vector — sub-step 4+).
const Kind = enum { word, aggregate, unsupported };
fn kindOf(table: *const types.TypeTable, ty: TypeId) Kind {
switch (ty) {
.bool, .i8, .u8, .i16, .u16, .i32, .u32, .f32, .i64, .u64, .f64, .usize, .isize, .cstring => return .word,
// A comptime `Type` value is an 8-byte handle (a `TypeId` in a word) —
// distinct from the 16-byte boxed `.any`. It rides as a word.
.type_value => return .word,
.string => return .aggregate, // {ptr,len} fat pointer (16B), by-address
.any => return .aggregate, // boxed { type_tag, value } (16B), by-address
else => {},
}
if (ty.isBuiltin()) return .unsupported; // void, noreturn, unresolved
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
// A tagged union is a `{ tag@0, [N x i8] payload@tag_size }` value held
// by-address (like a struct) — same as the `enum_init` write path.
.@"struct", .array, .tuple, .slice, .tagged_union => .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,
else => .unsupported,
};
}
/// 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 pointer-shaped (word) type — picks the `void*`-ABI extern-return trampoline
/// (`callPtrRet`) over the `i64`-ABI one. `cstring` plus any `pointer` /
/// `many_pointer` / `function`; a non-pointer optional folds to its child word.
fn isPointerish(table: *const types.TypeTable, ty: TypeId) bool {
if (ty == .cstring) return true;
if (ty.isBuiltin()) return false;
return switch (table.get(ty)) {
.pointer, .many_pointer, .function => true,
.optional => |o| optChildIsPtr(table, o.child),
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 {
if (child.isBuiltin()) return false;
return switch (table.get(child)) {
.pointer, .many_pointer, .function => true,
else => false,
};
}
/// 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) 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 (try self.machine.readWord(v + table.typeSizeBytes(child), 1)) != 0;
}
/// Read a value of type `ty` from comptime 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 => {
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 comptime memory (slice/optional/enum/array/etc.)";
return error.Unsupported;
},
};
}
/// Write register word `val` (of type `ty`) to comptime address `addr`: a scalar
/// writes its bytes; an aggregate copies `sizeof(ty)` bytes from `val` (its
/// 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 => try self.machine.writeWord(addr, table.typeSizeBytes(ty), val),
.aggregate => {
const n = table.typeSizeBytes(ty);
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 comptime memory (slice/optional/enum/array/etc.)";
return error.Unsupported;
},
}
}
/// 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. 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 {
// string/slice `{ptr@0, len@8}` and the boxed Any `{type_tag@0, value@8}`
// share the same two-8-byte-field layout.
if (sty == .string or sty == .any 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| {
off = std.mem.alignForward(usize, off, table.typeAlignBytes(f.ty));
if (i == idx) return @intCast(off);
off += table.typeSizeBytes(f.ty);
}
return @intCast(off);
}
/// The struct type a `FieldAccess` operates on: the explicit `base_type` when
/// lowering set it, else the base operand's Ref type — dereferenced when the
/// base is a POINTER (`struct_gep` on an `alloca` result is `*S` → `S`).
fn aggType(self: *Vm, table: *const types.TypeTable, fa: inst_mod.FieldAccess, ref_types: []const TypeId) Error!TypeId {
// The explicit `base_type` when lowering set it, else the base operand's
// Ref type. Either way, deref ONE pointer level when the result is a
// pointer-to-struct: a `struct_gep`/`struct_get` on a `*Struct` receiver
// (e.g. `list.field` where `list: *List`) computes the field offset on the
// POINTEE struct, with the base register already holding the pointer
// address. Lowering sets `base_type = *Struct` on the write/lvalue path.
const raw = fa.base_type orelse (try self.refTy(ref_types, fa.base));
if (!raw.isBuiltin() and table.get(raw) == .pointer) return table.get(raw).pointer.pointee;
return raw;
}
/// The byte offset of tuple element `idx` — the positional analogue of
/// `fieldOffset` (each element aligned to its own alignment, in order).
fn tupleFieldOffset(table: *const types.TypeTable, tty: TypeId, idx: u32) Addr {
const fields = table.get(tty).tuple.fields;
var off: usize = 0;
for (fields, 0..) |fty, i| {
off = std.mem.alignForward(usize, off, table.typeAlignBytes(fty));
if (i == idx) return @intCast(off);
off += table.typeSizeBytes(fty);
}
return @intCast(off);
}
/// The pointee of a single-element pointer type (the result of `index_gep` is
/// `*element`). Falls back to `ty` if it isn't a `.pointer` (the caller only
/// uses the result for an element-size query).
fn pointeeOf(table: *const types.TypeTable, ty: TypeId) TypeId {
if (!ty.isBuiltin()) {
const info = table.get(ty);
if (info == .pointer) return info.pointer.pointee;
}
return ty;
}
/// Address of element `idx_word` in `base`: `data + idx * elem_size`, where
/// `data` is `base` itself for a directly-addressable base (`array` / `pointer`
/// / `many_pointer` / `cstring`) or the loaded `.ptr` field for a fat-pointer
/// 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 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";
return error.Unsupported;
}
break :blk switch (table.get(base_ty)) {
.array, .pointer, .many_pointer => base,
.slice => try self.machine.readWord(base, table.pointer_size),
else => {
self.detail = "comptime VM: indexing a non-array/pointer/slice base";
return error.Unsupported;
},
};
};
const idx: u64 = @bitCast(idx_word); // non-negative comptime index
return data +% idx *% @as(u64, @intCast(elem_size));
}
/// Materialize `text` into comptime memory as a `string` VALUE — NUL-terminated
/// bytes + a `{ptr, len}` fat pointer (len excludes the NUL). Shared by
/// `text_of` and `type_info`'s variant/field-name construction.
fn makeStringValue(self: *Vm, table: *const types.TypeTable, text: []const u8) Error!Reg {
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);
}
/// Build a `{ptr, len}` fat pointer (slice/string value) in comptime 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) Error!Addr {
const fp = self.machine.allocBytes(16, 8);
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) 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) Error!Addr {
return self.machine.readWord(base, table.pointer_size);
}
/// Build a `List(string)` aggregate in comptime memory from host strings and
/// return its Addr (the VM's aggregate value IS its address). `list_ty` is
/// the result type of the calling primitive (`List(string)`); its field
/// offsets/types drive the layout (target-aware via the table), so this works
/// for any `{ items: [*]string, len: i64, cap: i64 }`-shaped struct. Used by
/// the metadata-query compiler primitives (`c_object_paths`/`link_libraries`).
fn makeStringList(self: *Vm, table: *const types.TypeTable, list_ty: TypeId, items: []const []const u8) Error!Reg {
if (list_ty.isBuiltin() or table.get(list_ty) != .@"struct")
return self.failMsg("comptime List builder: result type is not a List struct");
const str_size = table.typeSizeBytes(.string);
// Backing array of `items.len` `string` fat pointers (null when empty —
// the List's `items` is then a null `[*]string`, matching `len`/`cap` 0).
const backing: Addr = if (items.len == 0) null_addr else self.machine.allocBytes(items.len * str_size, 8);
for (items, 0..) |s, i| {
try self.writeField(table, backing + i * str_size, .string, try self.makeStringValue(table, s));
}
// The List struct: field 0 = items ([*]string), 1 = len (i64), 2 = cap (i64).
const addr = self.machine.allocBytes(table.typeSizeBytes(list_ty), 8);
const items_fty = table.memberType(list_ty, 0) orelse
return self.failMsg("comptime List builder: result type has no items field");
const len_fty = table.memberType(list_ty, 1) orelse
return self.failMsg("comptime List builder: result type has no len field");
const cap_fty = table.memberType(list_ty, 2) orelse
return self.failMsg("comptime List builder: result type has no cap field");
const n: Reg = @bitCast(@as(i64, @intCast(items.len)));
try self.writeField(table, addr + fieldOffset(table, list_ty, 0), items_fty, backing);
try self.writeField(table, addr + fieldOffset(table, list_ty, 1), len_fty, n);
try self.writeField(table, addr + fieldOffset(table, list_ty, 2), cap_fty, n);
return addr;
}
/// Read a `string` argument (a `{ptr, len}` fat pointer at `val`) as a host
/// `[]const u8`. The bytes are a VIEW into comptime memory (Addr is a real host
/// pointer over a stable arena), valid for the duration of the call.
fn readStringArg(self: *Vm, table: *const types.TypeTable, val: Reg) Error![]const u8 {
const len: usize = @intCast(try self.sliceLen(val));
if (len == 0) return "";
return try self.machine.bytes(try self.sliceData(table, val), len);
}
/// Read a `List(string)` aggregate (at `addr`) into a host `[][]const u8` —
/// the inverse of `makeStringList`. Element string bytes are VIEWS into comptime
/// memory (stable arena); the outer array is gpa-allocated (freed at
/// `Vm.deinit`). Used by the `link` primitive to read its List args.
fn readStringList(self: *Vm, table: *const types.TypeTable, list_ty: TypeId, addr: Addr) Error![]const []const u8 {
if (list_ty.isBuiltin() or table.get(list_ty) != .@"struct")
return self.failMsg("comptime List reader: arg type is not a List struct");
const items_ptr = try self.machine.readWord(addr + fieldOffset(table, list_ty, 0), table.pointer_size);
const len: usize = @intCast(try self.machine.readWord(addr + fieldOffset(table, list_ty, 1), 8));
const str_size = table.typeSizeBytes(.string);
const out = self.gpa.alloc([]const u8, len) catch return self.failMsg("comptime List reader: out of memory");
var i: usize = 0;
while (i < len) : (i += 1) {
out[i] = try self.readStringArg(table, items_ptr + i * str_size);
}
return out;
}
};