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:
@@ -480,6 +480,61 @@ test "comptime_vm exec: non-pointer optional wrap/unwrap/has_value/coalesce" {
|
||||
try std.testing.expectEqual(@as(i64, 91), toI64(try v.run(&fb.func, &.{})));
|
||||
}
|
||||
|
||||
test "comptime_vm exec: a negative i32 stored and reloaded stays negative (sign-extend)" {
|
||||
// Regression (failable cluster): the legacy `.int` model is i64. Storing an
|
||||
// i32 -1 writes 0xFFFFFFFF; the load must SIGN-extend (not zero-extend, which
|
||||
// would read +4294967295 and make `< 0` false — the bug that hid `raise`).
|
||||
const alloc = std.testing.allocator;
|
||||
var table = types.TypeTable.init(alloc);
|
||||
defer table.deinit();
|
||||
const i32ptr = table.intern(.{ .pointer = .{ .pointee = .i32 } });
|
||||
|
||||
// p := alloca i32; *p = -1; return (load p) < 0 ? 1 : 0 → 1
|
||||
var fb = Fb.init(alloc, &.{}, .i64);
|
||||
defer fb.deinit();
|
||||
const b0 = fb.block(&.{});
|
||||
const p = fb.add(b0, inst(.{ .alloca = .i32 }, i32ptr));
|
||||
const neg1 = fb.add(b0, inst(.{ .const_int = -1 }, .i32));
|
||||
_ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(p), .val = ref(neg1), .val_ty = .i32 } }, .void));
|
||||
const ld = fb.add(b0, inst(.{ .load = .{ .operand = ref(p) } }, .i32));
|
||||
const z = fb.add(b0, inst(.{ .const_int = 0 }, .i32));
|
||||
const lt = fb.add(b0, inst(.{ .cmp_lt = .{ .lhs = ref(ld), .rhs = ref(z) } }, .bool));
|
||||
_ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(lt) } }, .void));
|
||||
|
||||
var v = vm.Vm.init(alloc);
|
||||
v.table = &table;
|
||||
defer v.deinit();
|
||||
try std.testing.expectEqual(@as(i64, 1), toI64(try v.run(&fb.func, &.{})));
|
||||
}
|
||||
|
||||
test "comptime_vm exec: storing a null non-pointer optional into a slot reads back as none" {
|
||||
// Regression for the implicit-ctx coverage pass: `y: ?i64 = null` lowers to a
|
||||
// store of the `null_addr` optional sentinel into an aggregate slot. writeField
|
||||
// must ZERO the slot (→ flag byte 0 → none), not memcpy from address 0 (OOB).
|
||||
const alloc = std.testing.allocator;
|
||||
var table = types.TypeTable.init(alloc);
|
||||
defer table.deinit();
|
||||
const opt_i64 = table.intern(.{ .optional = .{ .child = .i64 } });
|
||||
const opt_ptr = table.intern(.{ .pointer = .{ .pointee = opt_i64 } });
|
||||
|
||||
// s := alloca ?i64; *s = null; return (load s) ?? 99 → 99
|
||||
var fb = Fb.init(alloc, &.{}, .i64);
|
||||
defer fb.deinit();
|
||||
const b0 = fb.block(&.{});
|
||||
const s = fb.add(b0, inst(.{ .alloca = opt_i64 }, opt_ptr));
|
||||
const n = fb.add(b0, inst(.const_null, opt_i64));
|
||||
_ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(s), .val = ref(n), .val_ty = opt_i64 } }, .void));
|
||||
const ld = fb.add(b0, inst(.{ .load = .{ .operand = ref(s) } }, opt_i64));
|
||||
const d = fb.add(b0, inst(.{ .const_int = 99 }, .i64));
|
||||
const co = fb.add(b0, inst(.{ .optional_coalesce = .{ .lhs = ref(ld), .rhs = ref(d) } }, .i64));
|
||||
_ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(co) } }, .void));
|
||||
|
||||
var v = vm.Vm.init(alloc);
|
||||
v.table = &table;
|
||||
defer v.deinit();
|
||||
try std.testing.expectEqual(@as(i64, 99), toI64(try v.run(&fb.func, &.{})));
|
||||
}
|
||||
|
||||
test "comptime_vm exec: pointer optional (null == 0)" {
|
||||
const alloc = std.testing.allocator;
|
||||
var table = types.TypeTable.init(alloc);
|
||||
@@ -570,6 +625,181 @@ test "comptime_vm exec: deref a pointer; addr_of passes through a struct address
|
||||
try std.testing.expectEqual(@as(i64, 80), toI64(try vm_.run(&fb.func, &.{})));
|
||||
}
|
||||
|
||||
test "comptime_vm exec: f32 store/load round-trips through 4-byte memory" {
|
||||
// Float registers hold f64 bits; f32 memory is the 4-byte IEEE-754 single.
|
||||
// Regression: storing an f32 must @floatCast (NOT truncate the f64 bits — that
|
||||
// wrote zeros for 1.0, since 1.0f64 = 0x3FF0000000000000, low 4 bytes = 0).
|
||||
const alloc = std.testing.allocator;
|
||||
var table = types.TypeTable.init(alloc);
|
||||
defer table.deinit();
|
||||
const f32ptr = table.intern(.{ .pointer = .{ .pointee = .f32 } });
|
||||
|
||||
// p := alloca f32; *p = 1.0; return int(load p) → 1 (was 0 under the bug)
|
||||
var fb = Fb.init(alloc, &.{}, .i64);
|
||||
defer fb.deinit();
|
||||
const b0 = fb.block(&.{});
|
||||
const p = fb.add(b0, inst(.{ .alloca = .f32 }, f32ptr));
|
||||
const c = fb.add(b0, inst(.{ .const_float = 1.0 }, .f32));
|
||||
_ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(p), .val = ref(c), .val_ty = .f32 } }, .void));
|
||||
const l = fb.add(b0, inst(.{ .load = .{ .operand = ref(p) } }, .f32));
|
||||
const i = fb.add(b0, inst(.{ .float_to_int = .{ .operand = ref(l), .from = .f32, .to = .i64 } }, .i64));
|
||||
_ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(i) } }, .void));
|
||||
|
||||
var v = vm.Vm.init(alloc);
|
||||
v.table = &table;
|
||||
defer v.deinit();
|
||||
try std.testing.expectEqual(@as(i64, 1), toI64(try v.run(&fb.func, &.{})));
|
||||
}
|
||||
|
||||
test "comptime_vm exec: malloc builtin gives usable flat memory; free is a no-op" {
|
||||
const alloc = std.testing.allocator;
|
||||
var module = Module.init(alloc);
|
||||
defer module.deinit();
|
||||
const u8ptr = module.types.intern(.{ .many_pointer = .{ .element = .u8 } });
|
||||
const u8single = module.types.intern(.{ .pointer = .{ .pointee = .u8 } });
|
||||
|
||||
// extern malloc(size: usize) -> [*]u8 (FuncId 0, no body)
|
||||
const malloc_params = [_]Function.Param{.{ .name = module.types.internString("size"), .ty = .usize }};
|
||||
var mfb = Fb.init(alloc, &malloc_params, u8ptr);
|
||||
mfb.func.is_extern = true;
|
||||
mfb.func.name = module.types.internString("malloc");
|
||||
const malloc_id = module.addFunction(mfb.func);
|
||||
|
||||
// extern free(p: [*]u8) (FuncId 1, no body)
|
||||
const free_params = [_]Function.Param{.{ .name = module.types.internString("p"), .ty = u8ptr }};
|
||||
var ffb = Fb.init(alloc, &free_params, .void);
|
||||
ffb.func.is_extern = true;
|
||||
ffb.func.name = module.types.internString("free");
|
||||
const free_id = module.addFunction(ffb.func);
|
||||
|
||||
// main(): buf := malloc(8); buf[3] = 0x42; r := buf[3]; free(buf); return r → 66
|
||||
var fb = Fb.init(alloc, &.{}, .i64);
|
||||
const b0 = fb.block(&.{});
|
||||
const sz = fb.add(b0, inst(.{ .const_int = 8 }, .usize));
|
||||
const margs = [_]Ref{ref(sz)};
|
||||
const buf = fb.add(b0, inst(.{ .call = .{ .callee = malloc_id, .args = &margs } }, u8ptr));
|
||||
const idx = fb.add(b0, inst(.{ .const_int = 3 }, .i64));
|
||||
const g = fb.add(b0, inst(.{ .index_gep = .{ .lhs = ref(buf), .rhs = ref(idx) } }, u8single));
|
||||
const val = fb.add(b0, inst(.{ .const_int = 0x42 }, .u8));
|
||||
_ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(g), .val = ref(val), .val_ty = .u8 } }, .void));
|
||||
const idx2 = fb.add(b0, inst(.{ .const_int = 3 }, .i64));
|
||||
const r = fb.add(b0, inst(.{ .index_get = .{ .lhs = ref(buf), .rhs = ref(idx2) } }, .u8));
|
||||
const fargs = [_]Ref{ref(buf)};
|
||||
_ = fb.add(b0, inst(.{ .call = .{ .callee = free_id, .args = &fargs } }, .void));
|
||||
_ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(r) } }, .void));
|
||||
const main_id = module.addFunction(fb.func);
|
||||
|
||||
var v = vm.Vm.init(alloc);
|
||||
v.table = &module.types;
|
||||
v.module = &module;
|
||||
defer v.deinit();
|
||||
try std.testing.expectEqual(@as(i64, 66), toI64(try v.run(module.getFunction(main_id), &.{})));
|
||||
}
|
||||
|
||||
test "comptime_vm exec: global_get evaluates a comptime global (lazy + cached)" {
|
||||
const alloc = std.testing.allocator;
|
||||
var module = Module.init(alloc);
|
||||
defer module.deinit();
|
||||
|
||||
// fn base() -> i64 { return 25 } (FuncId 0) — the global's comptime_func
|
||||
var bf = Fb.init(alloc, &.{}, .i64);
|
||||
const bfb = bf.block(&.{});
|
||||
const c25 = bf.add(bfb, inst(.{ .const_int = 25 }, .i64));
|
||||
_ = bf.add(bfb, inst(.{ .ret = .{ .operand = ref(c25) } }, .void));
|
||||
const base_id = module.addFunction(bf.func);
|
||||
|
||||
// global G :: comptime base() (GlobalId 0)
|
||||
const g = module.addGlobal(.{ .name = module.types.internString("G"), .ty = .i64, .comptime_func = base_id });
|
||||
|
||||
// fn main() -> i64 { return G + G + 5 } → 25 + 25 + 5 = 55 (second read is cached)
|
||||
var fb = Fb.init(alloc, &.{}, .i64);
|
||||
const b0 = fb.block(&.{});
|
||||
const a = fb.add(b0, inst(.{ .global_get = g }, .i64));
|
||||
const b = fb.add(b0, inst(.{ .global_get = g }, .i64));
|
||||
const five = fb.add(b0, inst(.{ .const_int = 5 }, .i64));
|
||||
const s1 = fb.add(b0, inst(.{ .add = .{ .lhs = ref(a), .rhs = ref(b) } }, .i64));
|
||||
const s2 = fb.add(b0, inst(.{ .add = .{ .lhs = ref(s1), .rhs = ref(five) } }, .i64));
|
||||
_ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(s2) } }, .void));
|
||||
const main_id = module.addFunction(fb.func);
|
||||
|
||||
var v = vm.Vm.init(alloc);
|
||||
v.table = &module.types;
|
||||
v.module = &module;
|
||||
defer v.deinit();
|
||||
try std.testing.expectEqual(@as(i64, 55), toI64(try v.run(module.getFunction(main_id), &.{})));
|
||||
}
|
||||
|
||||
test "comptime_vm exec: compiler-fn intern/text_of round-trip (native, no legacy interp)" {
|
||||
const alloc = std.testing.allocator;
|
||||
var module = Module.init(alloc);
|
||||
defer module.deinit();
|
||||
|
||||
// extern intern(s: string) -> u32 [compiler] (FuncId 0, no body)
|
||||
const ip = [_]Function.Param{.{ .name = module.types.internString("s"), .ty = .string }};
|
||||
var ifb = Fb.init(alloc, &ip, .u32);
|
||||
ifb.func.is_extern = true;
|
||||
ifb.func.compiler_welded = true;
|
||||
ifb.func.name = module.types.internString("intern");
|
||||
const intern_id = module.addFunction(ifb.func);
|
||||
|
||||
// extern text_of(id: u32) -> string [compiler] (FuncId 1, no body)
|
||||
const tp = [_]Function.Param{.{ .name = module.types.internString("id"), .ty = .u32 }};
|
||||
var tfb = Fb.init(alloc, &tp, .string);
|
||||
tfb.func.is_extern = true;
|
||||
tfb.func.compiler_welded = true;
|
||||
tfb.func.name = module.types.internString("text_of");
|
||||
const textof_id = module.addFunction(tfb.func);
|
||||
|
||||
// main(): return length(text_of(intern("hello"))) → 5
|
||||
var fb = Fb.init(alloc, &.{}, .i64);
|
||||
const b0 = fb.block(&.{});
|
||||
const s = fb.add(b0, inst(.{ .const_string = module.types.internString("hello") }, .string));
|
||||
const sargs = [_]Ref{ref(s)};
|
||||
const id = fb.add(b0, inst(.{ .call = .{ .callee = intern_id, .args = &sargs } }, .u32));
|
||||
const iargs = [_]Ref{ref(id)};
|
||||
const back = fb.add(b0, inst(.{ .call = .{ .callee = textof_id, .args = &iargs } }, .string));
|
||||
const len = fb.add(b0, inst(.{ .length = .{ .operand = ref(back) } }, .i64));
|
||||
_ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(len) } }, .void));
|
||||
const main_id = module.addFunction(fb.func);
|
||||
|
||||
var v = vm.Vm.init(alloc);
|
||||
v.table = &module.types;
|
||||
v.module = &module;
|
||||
defer v.deinit();
|
||||
try std.testing.expectEqual(@as(i64, 5), toI64(try v.run(module.getFunction(main_id), &.{})));
|
||||
}
|
||||
|
||||
test "comptime_vm exec: func_ref + call_indirect dispatch" {
|
||||
const alloc = std.testing.allocator;
|
||||
var module = Module.init(alloc);
|
||||
defer module.deinit();
|
||||
|
||||
// fn dbl(x) = x * 2 (FuncId 0)
|
||||
const dbl_params = [_]Function.Param{.{ .name = dummy, .ty = .i64 }};
|
||||
var db = Fb.init(alloc, &dbl_params, .i64);
|
||||
const dbb = db.block(&.{});
|
||||
const two = db.add(dbb, inst(.{ .const_int = 2 }, .i64));
|
||||
const prod = db.add(dbb, inst(.{ .mul = .{ .lhs = ref(0), .rhs = ref(two) } }, .i64));
|
||||
_ = db.add(dbb, inst(.{ .ret = .{ .operand = ref(prod) } }, .void));
|
||||
const dbl_id = module.addFunction(db.func);
|
||||
|
||||
// fn main() = call_indirect(func_ref(dbl), [21]) → 42
|
||||
var fb = Fb.init(alloc, &.{}, .i64);
|
||||
const b0 = fb.block(&.{});
|
||||
const fr = fb.add(b0, inst(.{ .func_ref = dbl_id }, .i64));
|
||||
const c21 = fb.add(b0, inst(.{ .const_int = 21 }, .i64));
|
||||
const cargs = [_]Ref{ref(c21)};
|
||||
const r = fb.add(b0, inst(.{ .call_indirect = .{ .callee = ref(fr), .args = &cargs } }, .i64));
|
||||
_ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(r) } }, .void));
|
||||
const main_id = module.addFunction(fb.func);
|
||||
|
||||
var v = vm.Vm.init(alloc);
|
||||
v.table = &module.types;
|
||||
v.module = &module;
|
||||
defer v.deinit();
|
||||
try std.testing.expectEqual(@as(i64, 42), toI64(try v.run(module.getFunction(main_id), &.{})));
|
||||
}
|
||||
|
||||
test "comptime_vm exec: direct call to another function" {
|
||||
const alloc = std.testing.allocator;
|
||||
var module = Module.init(alloc);
|
||||
@@ -761,8 +991,8 @@ test "comptime_vm: writeWord/readWord round-trip at each scalar size" {
|
||||
const vals = [_]u64{ 0xAB, 0xBEEF, 0xDEADBEEF, 0x0123456789ABCDEF };
|
||||
for (sizes, vals) |size, val| {
|
||||
const addr = m.allocBytes(size, size);
|
||||
m.writeWord(addr, size, val);
|
||||
try std.testing.expectEqual(val, m.readWord(addr, size));
|
||||
try m.writeWord(addr, size, val);
|
||||
try std.testing.expectEqual(val, try m.readWord(addr, size));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,8 +1003,8 @@ test "comptime_vm: writeWord truncates to size and readWord zero-extends" {
|
||||
// Write a full 64-bit word's worth of bits through a 1-byte store: only the
|
||||
// low byte lands; the read zero-extends it.
|
||||
const addr = m.allocBytes(1, 1);
|
||||
m.writeWord(addr, 1, 0xFFFF_FF42);
|
||||
try std.testing.expectEqual(@as(u64, 0x42), m.readWord(addr, 1));
|
||||
try m.writeWord(addr, 1, 0xFFFF_FF42);
|
||||
try std.testing.expectEqual(@as(u64, 0x42), try m.readWord(addr, 1));
|
||||
}
|
||||
|
||||
test "comptime_vm: bytes() view reflects word writes (little-endian)" {
|
||||
@@ -782,14 +1012,70 @@ test "comptime_vm: bytes() view reflects word writes (little-endian)" {
|
||||
defer m.deinit();
|
||||
|
||||
const addr = m.allocBytes(4, 4);
|
||||
m.writeWord(addr, 4, 0xDEADBEEF);
|
||||
const view = m.bytes(addr, 4);
|
||||
try m.writeWord(addr, 4, 0xDEADBEEF);
|
||||
const view = try m.bytes(addr, 4);
|
||||
try std.testing.expectEqual(@as(u8, 0xEF), view[0]);
|
||||
try std.testing.expectEqual(@as(u8, 0xBE), view[1]);
|
||||
try std.testing.expectEqual(@as(u8, 0xAD), view[2]);
|
||||
try std.testing.expectEqual(@as(u8, 0xDE), view[3]);
|
||||
}
|
||||
|
||||
test "comptime_vm: a malformed operand ref (Ref.none) bails, not a panic" {
|
||||
// A `ret` whose operand is `Ref.none` (0xFFFFFFFF) — the kind of malformed IR
|
||||
// an unresolved name leaves behind. `Frame.get` must flip `bad_ref` and the run
|
||||
// must bail (error.Unsupported), never index out of bounds and panic.
|
||||
var fb = Fb.init(std.testing.allocator, &.{}, .i64);
|
||||
defer fb.deinit();
|
||||
const b0 = fb.block(&.{});
|
||||
_ = fb.add(b0, inst(.{ .ret = .{ .operand = Ref.none } }, .void));
|
||||
|
||||
var v = vm.Vm.init(std.testing.allocator);
|
||||
defer v.deinit();
|
||||
try std.testing.expectError(error.Unsupported, v.run(&fb.func, &.{}));
|
||||
}
|
||||
|
||||
test "comptime_vm: hardened accessors return OutOfBounds, not a panic" {
|
||||
var m = vm.Machine.init(std.testing.allocator);
|
||||
defer m.deinit();
|
||||
|
||||
const addr = m.allocBytes(8, 8);
|
||||
|
||||
// Null address (reserved guard) → OutOfBounds on every accessor.
|
||||
try std.testing.expectError(error.OutOfBounds, m.readWord(vm.null_addr, 8));
|
||||
try std.testing.expectError(error.OutOfBounds, m.writeWord(vm.null_addr, 8, 0));
|
||||
try std.testing.expectError(error.OutOfBounds, m.bytes(vm.null_addr, 4));
|
||||
|
||||
// Past the end of allocated memory → OutOfBounds.
|
||||
const past = m.mark() + 64;
|
||||
try std.testing.expectError(error.OutOfBounds, m.readWord(@intCast(past), 1));
|
||||
try std.testing.expectError(error.OutOfBounds, m.bytes(@intCast(past), 1));
|
||||
|
||||
// Straddling the end (last valid byte + an oversized read) → OutOfBounds.
|
||||
try std.testing.expectError(error.OutOfBounds, m.readWord(addr + 4, 8));
|
||||
|
||||
// A zero-length view is always valid (no memory touched), even at null.
|
||||
try std.testing.expectEqual(@as(usize, 0), (try m.bytes(vm.null_addr, 0)).len);
|
||||
}
|
||||
|
||||
test "comptime_vm tryEval: deref of a null pointer bails (null, not a crash)" {
|
||||
const alloc = std.testing.allocator;
|
||||
var module = Module.init(alloc);
|
||||
defer module.deinit();
|
||||
const i64ptr = module.types.intern(.{ .pointer = .{ .pointee = .i64 } });
|
||||
|
||||
// fn bad() -> i64 { p := (null : *i64); return p.* } → reads through addr 0.
|
||||
var fb = Fb.init(alloc, &.{}, .i64);
|
||||
const b0 = fb.block(&.{});
|
||||
const p = fb.add(b0, inst(.const_null, i64ptr));
|
||||
const d = fb.add(b0, inst(.{ .deref = .{ .operand = ref(p) } }, .i64));
|
||||
_ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(d) } }, .void));
|
||||
const bad_id = module.addFunction(fb.func);
|
||||
|
||||
// The hardened accessors turn the null deref into error.OutOfBounds → run
|
||||
// bails → tryEval returns null (legacy fallback), NOT a debug panic.
|
||||
try std.testing.expect(vm.tryEval(alloc, &module, bad_id) == null);
|
||||
}
|
||||
|
||||
test "comptime_vm: mark/reset reclaims the stack region" {
|
||||
var m = vm.Machine.init(std.testing.allocator);
|
||||
defer m.deinit();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,6 +32,8 @@ const Module = ir_module.Module;
|
||||
const interp_mod = @import("interp.zig");
|
||||
const Interpreter = interp_mod.Interpreter;
|
||||
const Value = interp_mod.Value;
|
||||
const comptime_vm = @import("comptime_vm.zig");
|
||||
const build_opts = @import("build_opts");
|
||||
|
||||
// The vendored error-trace ring buffer (library/vendors/sx_trace_runtime/sx_trace.c)
|
||||
// is linked into the compiler. Comptime `#run` evaluation pushes frames to it via
|
||||
@@ -113,6 +115,18 @@ pub const LLVMEmitter = struct {
|
||||
// file or the JIT — the emit-time diagnostic is the surfaced error.
|
||||
comptime_failed: bool = false,
|
||||
|
||||
// When set (env `SX_COMPTIME_FLAT`, → a `-Dcomptime-flat` build flag later),
|
||||
// comptime const-init folds try the flat-memory VM (`comptime_vm.tryEval`)
|
||||
// first and fall back to the legacy tagged interpreter on null. Default OFF so
|
||||
// the corpus is unaffected until the VM reaches parity (Phase 1.final step d).
|
||||
comptime_flat: bool = false,
|
||||
|
||||
// When set (env `SX_COMPTIME_FLAT_TRACE`, only meaningful with `comptime_flat`),
|
||||
// each comptime const-init reports to stderr whether the VM handled it or fell
|
||||
// back to the legacy interpreter (with the bail reason) — the coverage signal
|
||||
// for porting the next ops. Default OFF.
|
||||
comptime_flat_trace: bool = false,
|
||||
|
||||
// Allocator for temporary bookkeeping
|
||||
alloc: Allocator,
|
||||
|
||||
@@ -321,6 +335,10 @@ pub const LLVMEmitter = struct {
|
||||
.build_config = .{},
|
||||
.di_files = std.StringHashMap(c.LLVMMetadataRef).init(alloc),
|
||||
.frame_str_cache = std.StringHashMap(c.LLVMValueRef).init(alloc),
|
||||
// Enabled by the `-Dcomptime-flat` build flag OR the `SX_COMPTIME_FLAT`
|
||||
// env var (either turns it on); default OFF (legacy interpreter).
|
||||
.comptime_flat = build_opts.comptime_flat or std.c.getenv("SX_COMPTIME_FLAT") != null,
|
||||
.comptime_flat_trace = std.c.getenv("SX_COMPTIME_FLAT_TRACE") != null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -845,19 +863,39 @@ pub const LLVMEmitter = struct {
|
||||
Interpreter.last_bail_op = null;
|
||||
Interpreter.last_bail_builtin = null;
|
||||
Interpreter.last_bail_detail = null;
|
||||
const result = interp_inst.call(func_id, &.{}) catch |err| blk: {
|
||||
// A comptime `#run` side-effect that bails must NOT silently
|
||||
// truncate its output and still ship a successful build.
|
||||
// Surface the bail loudly and fail the build, mirroring the
|
||||
// const-init path in emitGlobals. Whatever output the run
|
||||
// produced before the bail is flushed below so the user sees
|
||||
// where execution stopped.
|
||||
const op = Interpreter.last_bail_op orelse "<unknown>";
|
||||
const detail = Interpreter.last_bail_detail orelse "";
|
||||
const sep: []const u8 = if (detail.len > 0) ": " else "";
|
||||
std.debug.print("error: comptime `#run` ({s}) failed: {s} (op={s}{s}{s})\n", .{ fname, @errorName(err), op, sep, detail });
|
||||
self.comptime_failed = true;
|
||||
break :blk Value.void_val;
|
||||
// Flat-memory VM fast path (gated by `SX_COMPTIME_FLAT`), same as the
|
||||
// const-init fold: a VM-handled side-effect that needs no `print`/extern
|
||||
// runs entirely on the VM (no buffered output); anything it can't handle
|
||||
// (`print`, an unported op) bails → `null` → the legacy interpreter below.
|
||||
const vm_result: ?Value = if (self.comptime_flat)
|
||||
comptime_vm.tryEval(self.alloc, self.ir_mod, func_id)
|
||||
else
|
||||
null;
|
||||
if (self.comptime_flat and self.comptime_flat_trace) {
|
||||
if (vm_result != null)
|
||||
std.debug.print("[comptime-vm] HANDLED run '{s}'\n", .{fname})
|
||||
else
|
||||
std.debug.print("[comptime-vm] fallback run '{s}': {s}\n", .{ fname, comptime_vm.last_bail_reason orelse "<unknown>" });
|
||||
}
|
||||
const result = vm_result orelse fallback: {
|
||||
// The VM bailed: discard any return-trace frames it pushed before
|
||||
// bailing (`sx_trace_push` is a side effect on the shared buffer),
|
||||
// else the legacy re-run double-pushes them (see 1035).
|
||||
if (self.comptime_flat) sx_trace_clear();
|
||||
break :fallback interp_inst.call(func_id, &.{}) catch |err| blk: {
|
||||
// A comptime `#run` side-effect that bails must NOT silently
|
||||
// truncate its output and still ship a successful build.
|
||||
// Surface the bail loudly and fail the build, mirroring the
|
||||
// const-init path in emitGlobals. Whatever output the run
|
||||
// produced before the bail is flushed below so the user sees
|
||||
// where execution stopped.
|
||||
const op = Interpreter.last_bail_op orelse "<unknown>";
|
||||
const detail = Interpreter.last_bail_detail orelse "";
|
||||
const sep: []const u8 = if (detail.len > 0) ": " else "";
|
||||
std.debug.print("error: comptime `#run` ({s}) failed: {s} (op={s}{s}{s})\n", .{ fname, @errorName(err), op, sep, detail });
|
||||
self.comptime_failed = true;
|
||||
break :blk Value.void_val;
|
||||
};
|
||||
};
|
||||
// Route #run `print` output to fd 1 so it joins the
|
||||
// JIT-executed runtime's stream. Same call site shape as
|
||||
@@ -925,17 +963,40 @@ pub const LLVMEmitter = struct {
|
||||
Interpreter.last_bail_builtin = null;
|
||||
Interpreter.last_bail_detail = null;
|
||||
sx_trace_clear();
|
||||
const result = interp_inst.call(func_id, &.{}) catch |err| blk: {
|
||||
// Surface the bail loudly instead of silently filling
|
||||
// the const with zero. Stale state from a previous
|
||||
// comptime function would otherwise hide the error.
|
||||
const op = Interpreter.last_bail_op orelse "<unknown>";
|
||||
const detail = Interpreter.last_bail_detail orelse "";
|
||||
const sep: []const u8 = if (detail.len > 0) ": " else "";
|
||||
// Flat-memory VM fast path (gated by `SX_COMPTIME_FLAT`): run the
|
||||
// comptime initializer on the VM; `null` (unsupported op / any
|
||||
// bail / implicit-ctx) falls through to the legacy interpreter
|
||||
// below, which produces the identical result. Default OFF.
|
||||
const vm_result: ?Value = if (self.comptime_flat)
|
||||
comptime_vm.tryEval(self.alloc, self.ir_mod, func_id)
|
||||
else
|
||||
null;
|
||||
// Coverage trace (gated): report whether the VM handled this
|
||||
// comptime init or fell back, and why — names what to port next.
|
||||
if (self.comptime_flat and self.comptime_flat_trace) {
|
||||
const gname = self.ir_mod.types.getString(global.name);
|
||||
std.debug.print("error: comptime init of '{s}' failed: {s} (op={s}{s}{s})\n", .{ gname, @errorName(err), op, sep, detail });
|
||||
self.comptime_failed = true;
|
||||
break :blk .void_val;
|
||||
if (vm_result != null) {
|
||||
std.debug.print("[comptime-vm] HANDLED init '{s}'\n", .{gname});
|
||||
} else {
|
||||
std.debug.print("[comptime-vm] fallback init '{s}': {s}\n", .{ gname, comptime_vm.last_bail_reason orelse "<unknown>" });
|
||||
}
|
||||
}
|
||||
const result = vm_result orelse fallback: {
|
||||
// The VM bailed: discard any return-trace frames it pushed
|
||||
// before bailing, so the legacy re-run doesn't double-push.
|
||||
if (self.comptime_flat) sx_trace_clear();
|
||||
break :fallback interp_inst.call(func_id, &.{}) catch |err| blk: {
|
||||
// Surface the bail loudly instead of silently filling
|
||||
// the const with zero. Stale state from a previous
|
||||
// comptime function would otherwise hide the error.
|
||||
const op = Interpreter.last_bail_op orelse "<unknown>";
|
||||
const detail = Interpreter.last_bail_detail orelse "";
|
||||
const sep: []const u8 = if (detail.len > 0) ": " else "";
|
||||
const gname = self.ir_mod.types.getString(global.name);
|
||||
std.debug.print("error: comptime init of '{s}' failed: {s} (op={s}{s}{s})\n", .{ gname, @errorName(err), op, sep, detail });
|
||||
self.comptime_failed = true;
|
||||
break :blk .void_val;
|
||||
};
|
||||
};
|
||||
// A bare failable `NAME :: #run f();`: the comptime function
|
||||
// returns the failable tuple; split it. Escaping error →
|
||||
|
||||
@@ -568,6 +568,12 @@ pub fn evalComptimeString(self: *Lowering, expr: *const Node) ?[:0]const u8 {
|
||||
// dealloc thunks at the bottom of the dispatch.
|
||||
const ct_func_id = self.createComptimeFunction("__insert", expr, .string);
|
||||
|
||||
// NOTE: the flat-memory VM is intentionally NOT wired at this LOWERING-time
|
||||
// site. Unlike the emit-time const-init / `#run` folds (which run on fully
|
||||
// lowered IR), lowering-time IR can be malformed (e.g. a `ret Ref.none` left by
|
||||
// an unresolved name — see `0737`), and routing that through the VM is out of
|
||||
// scope until the VM is fully hardened against arbitrary malformed IR. The
|
||||
// emit-time sites already give the VM full corpus coverage.
|
||||
var interp = interp_mod.Interpreter.init(self.module, self.alloc);
|
||||
defer interp.deinit();
|
||||
if (self.diagnostics) |d| if (d.import_sources) |sm| interp.setSourceMap(sm);
|
||||
|
||||
Reference in New Issue
Block a user