comptime VM: box_any/unbox_any + .any as a 16-byte flat-memory aggregate (Phase 4A.1)

Ported the Any-boxing conversion pair:
- box_any: alloc the 16-byte { type_tag@0, value@8 } box, tag = source TypeId
  index (matches the legacy comptime interp; runtime anyTag also normalizes
  arbitrary-width ints). Value slot holds a word source's scalar bytes (via
  writeField(source_type) so f32 round-trips) or an aggregate source's
  flat-memory ADDR (the runtime pointer-in-value-slot shape).
- unbox_any: read the value slot back (word -> readField; aggregate -> the
  stored ADDR).

Required promoting .any to a first-class flat-memory aggregate (was
kindOf -> .unsupported): kindOf(.any) = .aggregate (16B, by-address) and
fieldOffset special-cases .any to the {@0, @8} layout (shared with
string/slice). Without the latter a struct_get on an Any panicked
(union field 'struct' while 'any' is active) -- caught + fixed, no crash.

Updated two unit tests that used unbox_any as the "unported op" example ->
compiler_call; added a box->unbox round-trip test. 697/0 both gates + all
unit tests. The 6 box_any examples no longer bail at box_any (output matches
legacy) but fall back further at switch_br/type_name/out (later 4A steps).
This commit is contained in:
agra
2026-06-18 16:56:50 +03:00
parent 3283effa97
commit 1526d198e2
3 changed files with 79 additions and 9 deletions

View File

@@ -689,6 +689,26 @@ test "comptime_vm exec: tagged-union enum_init with payload lays out {tag@0, pay
try std.testing.expectEqual(@as(u64, 42), try v.machine.readWord(addr + 8, 8)); // payload
}
test "comptime_vm exec: box_any/unbox_any round-trips a scalar through the {tag, value} box" {
const alloc = std.testing.allocator;
var table = types.TypeTable.init(alloc);
defer table.deinit();
// a := box_any(42, i64); return unbox_any(a) → 42 (exercises both the 16-byte
// {tag@0, value@8} box write and the value-slot read-back).
var fb = Fb.init(alloc, &.{}, .i64);
defer fb.deinit();
const b0 = fb.block(&.{});
const c = fb.add(b0, inst(.{ .const_int = 42 }, .i64));
const a = fb.add(b0, inst(.{ .box_any = .{ .operand = ref(c), .source_type = .i64 } }, .any));
const u = fb.add(b0, inst(.{ .unbox_any = .{ .operand = ref(a) } }, .i64));
_ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(u) } }, .void));
var v = vm.Vm.init(alloc);
v.table = &table;
defer v.deinit();
try std.testing.expectEqual(@as(i64, 42), toI64(try v.run(&fb.func, &.{})));
}
test "comptime_vm exec: const_type yields a Type-value word; regToValue bridges it to .type_tag" {
const alloc = std.testing.allocator;
var table = types.TypeTable.init(alloc);
@@ -1245,11 +1265,12 @@ test "comptime_vm tryEval: pure function → Value; unsupported → null" {
const v = vm.tryEval(alloc, &module, ok_id) orelse return error.VmShouldHaveHandledIt;
try std.testing.expectEqual(@as(i64, 42), v.int);
// fn bad() { unbox_any(1) } → tryEval yields null (caller falls back to legacy)
// fn bad() { compiler_call() } → an unported op → tryEval yields null (caller
// falls back to legacy). (box_any/unbox_any are now VM-native; compiler_call is
// still unported until Phase 4D.)
var fb2 = Fb.init(alloc, &.{}, .void);
const c0 = fb2.block(&.{});
const c = fb2.add(c0, inst(.{ .const_int = 1 }, .i64));
_ = fb2.add(c0, inst(.{ .unbox_any = .{ .operand = ref(c) } }, .i64));
_ = fb2.add(c0, inst(.{ .compiler_call = .{ .name = 0, .args = &.{} } }, .void));
_ = fb2.add(c0, inst(.ret_void, .void));
const bad_id = module.addFunction(fb2.func);
@@ -1271,19 +1292,18 @@ test "comptime_vm exec: division by zero and unsupported op bail loudly" {
try std.testing.expectEqual(@as(i64, 4), toI64(try v.run(&fb.func, &.{ fromI64(12), fromI64(3) })));
try std.testing.expectError(error.DivisionByZero, v.run(&fb.func, &.{ fromI64(12), fromI64(0) }));
}
// A not-yet-ported op (unbox_any) → Unsupported with the op name in `detail`.
// A not-yet-ported op (compiler_call) → Unsupported with the op name in `detail`.
{
var fb = Fb.init(std.testing.allocator, &.{}, .void);
defer fb.deinit();
const b0 = fb.block(&.{});
const c = fb.add(b0, inst(.{ .const_int = 1 }, .i64));
_ = fb.add(b0, inst(.{ .unbox_any = .{ .operand = ref(c) } }, .i64));
_ = fb.add(b0, inst(.{ .compiler_call = .{ .name = 0, .args = &.{} } }, .void));
_ = fb.add(b0, inst(.ret_void, .void));
var v = vm.Vm.init(std.testing.allocator);
defer v.deinit();
try std.testing.expectError(error.Unsupported, v.run(&fb.func, &.{}));
try std.testing.expectEqualStrings("unbox_any", v.detail.?);
try std.testing.expectEqualStrings("compiler_call", v.detail.?);
}
}

View File

@@ -872,6 +872,38 @@ pub const Vm = struct {
.ret => |u| return .{ .ret = frame.get(u.operand.index()) },
.ret_void => return .ret_void,
// T → Any: a 16-byte box `{ type_tag: i64 @0, value: i64 @8 }` (the LLVM
// layout). The tag is the source TypeId index (matches the legacy comptime
// interp; runtime `anyTag` additionally normalizes arbitrary-width ints —
// an existing legacy/runtime split). The value slot holds a word source's
// scalar bytes, or an aggregate source's flat-memory ADDR (the runtime
// "pointer in the value slot" shape — see emit_llvm.coerceToI64's struct path).
.box_any => |ba| {
const table = try self.requireTable();
const sz = table.typeSizeBytes(.any); // 16
const addr = self.machine.allocBytes(sz, table.typeAlignBytes(.any));
@memset(try self.machine.bytes(addr, sz), 0);
try self.machine.writeWord(addr, 8, @as(Reg, ba.source_type.index()));
const v = frame.get(ba.operand.index());
switch (kindOf(table, ba.source_type)) {
.word => try self.writeField(table, addr + 8, ba.source_type, v),
.aggregate => try self.machine.writeWord(addr + 8, 8, v),
.unsupported => return self.failMsg("comptime VM: box_any of an unsupported source type (any/void/noreturn)"),
}
return .{ .value = addr };
},
// Any → T: read the value slot (offset 8). A word target reads its scalar
// bytes back; an aggregate target reads the stored ADDR (the boxed pointer).
.unbox_any => |ua| {
const table = try self.requireTable();
const base = frame.get(ua.operand.index()); // Addr of the {tag, value} box
switch (kindOf(table, ins.ty)) {
.word => return .{ .value = try self.readField(table, base + 8, ins.ty) },
.aggregate => return .{ .value = try self.machine.readWord(base + 8, 8) },
.unsupported => return self.failMsg("comptime VM: unbox_any to an unsupported target type"),
}
},
// Comptime metatype `#builtin`s (`declare`/`define`). The VM-native
// mirror of the legacy `execBuiltin` arms; an unmodeled builtin returns
// null → bail with its name → legacy fallback (dual-path parity).
@@ -1664,9 +1696,10 @@ pub const Vm = struct {
// distinct from the 16-byte boxed `.any`. It rides as a word.
.type_value => return .word,
.string => return .aggregate, // {ptr,len} fat pointer (16B), by-address
.any => return .aggregate, // boxed { type_tag, value } (16B), by-address
else => {},
}
if (ty.isBuiltin()) return .unsupported; // any (16B, different shape), void, noreturn, unresolved
if (ty.isBuiltin()) return .unsupported; // void, noreturn, unresolved
return switch (table.get(ty)) {
.pointer, .many_pointer, .function => .word,
.@"enum" => .word, // payloadless enum: i64 (or its backing) — a word
@@ -1787,7 +1820,9 @@ pub const Vm = struct {
/// 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))
// string/slice `{ptr@0, len@8}` and the boxed Any `{type_tag@0, value@8}`
// share the same two-8-byte-field layout.
if (sty == .string or sty == .any or (!sty.isBuiltin() and table.get(sty) == .slice))
return if (idx == 0) 0 else 8;
const fields = table.get(sty).@"struct".fields;
var off: usize = 0;