//! 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 ;` 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 ` 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; } };