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