comptime VM: VM-native metatype CONSTRUCTION — declare/define + tagged-union enum_init (P3.4 step 7)

The metatype type-construction builtins now run natively on the flat-memory
VM, so the construction examples run HANDLED end-to-end (no call_builtin
fallback to the legacy interp).

- Tagged-union enum_init WITH payload: allocate zeroed, write the tag at
  offset 0, copy the payload at tag_size ({ header, [N x i8] } layout).
- New .call_builtin exec arm -> callBuiltinVm (VM-native mirror of the legacy
  execBuiltinInner): declare(name) mints an empty forward nominal slot (shared
  declareNominal, also used by declare_type); define(handle, info) reads the
  TypeInfo tagged-union VALUE from flat memory and mints via defineFromInfo,
  a faithful port of legacy defineEnum/defineStruct/defineTuple (all-void enum
  -> real .enum per issue 0142, dup-name rejection, updatePreservingKey vs
  replaceKeyedInfo). Unmodeled builtins bail -> legacy fallback (dual-path).
- Refactored the []{name,ty} decode out of registerTypeVm into a shared
  decodeMemberSlice (+ decodeTypeSlice for bare-Type tuple elements).
- Correctness guard: enum_init/define assume a tag-headed layout, wrong for a
  backing_type tagged union (laid out as the backing struct) — both now bail
  loudly on backing_type != null rather than silent-clobber.

Examples 0614/0620/0621/0624/0632 run fully HANDLED on the VM; 0622/0623 run
define HANDLED then fall back at the still-unported type_info. VM output
byte-matches legacy for all 7. 697/0 both gates + all unit tests (added:
tagged-union enum_init payload layout).
This commit is contained in:
agra
2026-06-18 15:48:48 +03:00
parent eb68d9ed94
commit d0ebc55f99
3 changed files with 300 additions and 52 deletions

View File

@@ -26,21 +26,23 @@ with ONE welded mechanism. Branch: `reify` (off `master`). Update after every st
> breaks cross-compilation — host vs target layout — and loses the sandbox. A
> flat-memory VM keeps both while getting native bytes + speed.)
>
> **Next action (2026-06-18) — THE WALL IS BROKEN; paused at a clean 3-commit boundary.** The
> dedicated **`Type` builtin TypeId** (8B, distinct from the 16-byte `.any`) now exists and is
> wired end-to-end: foundation (`6844fb9`), resolver flip + full `.any`-ref migration (`94f60c5`),
> and the VM models `.type_value` natively (`554871b`). **697/0 BOTH gates + 494 unit tests.**
> `first_user` is now **100** (slots 2099 reserved builtin headroom so future builtins don't
> renumber user TypeIds / churn snapshots). The PAYOFF is now LANDED (`66005af`): the
> **WRITE side** (declare_type / register_type / pointer_to) is VM-native in `Vm.callCompilerFn`
> — the compiler-API type-fns (`0631`/`0635`) run **HANDLED end-to-end on the VM at LOWERING
> time** (parity-correct), the first lowering-time comptime to do so; they run on the zeroed
> lowering-time context (no allocation). **697/0 both gates + EXIT=0.** What's LEFT toward the
> end-state ONE evaluator: (1) re-express the metatype `define`/`make_enum` over the compiler-API
> + delete the bespoke interp arms (the `make_enum` examples still fall back cleanly through
> `call_builtin(define)`); (2) a REAL lowering-time Context (CAllocator thunk func-refs) for
> List-growing type-fns — deferred (no HANDLED type-fn allocates); (3) eventually flip the VM to
> default + delete `interp.zig`.
> **Next action (2026-06-18) — VM-native metatype CONSTRUCTION landed (step 7, uncommitted).** The
> metatype `declare`/`define` builtins + tagged-union `enum_init`-with-payload now run NATIVELY on
> the VM (new `.call_builtin` exec arm → `callBuiltinVm`/`defineFromInfo`, reading the `TypeInfo`
> value from FLAT MEMORY; faithful port of legacy `defineEnum`/`Struct`/`Tuple`). So the metatype
> CONSTRUCTION examples run **fully HANDLED** on the VM (no `call_builtin` fallback): `0614`/`0620`/
> `0621`/`0624`/`0632`; `0622`/`0623` define-HANDLED then fall back at the still-unported `type_info`.
> Both `enum_init`/`define` bail loudly on a `backing_type` tagged union (wrong layout) rather than
> silent-clobber. **697/0 BOTH gates + all unit tests** (added: tagged-union `enum_init` payload).
> NOT yet committed. **THE NEXT STEP: port `type_info`** (reflect a type → build the `TypeInfo`
> value in flat memory, the inverse of `define` — reuses tagged-union `enum_init`) so `0619`/`0622`/
> `0623` go fully HANDLED; then drive the SX_COMPTIME_FLAT_TRACE fallback list toward
> genuinely-non-comptime cases. Earlier landed: dedicated `Type` builtin TypeId (`6844fb9`/`94f60c5`/
> `554871b`); WRITE side declare_type/register_type/pointer_to VM-native (`66005af`); real
> lowering-time Context for allocating type-fns (`eb68d9e`). What's LEFT toward the end-state ONE
> evaluator: (1) finish porting the comptime corpus onto the VM (type_info next); (2) THEN flip the
> VM to default + delete `interp.zig` (with user go-ahead); (3) re-express `define`/`make_enum` as
> sx over the compiler-API once legacy is gone (allocation works only on the sole VM evaluator).
>
> Done so far in Phase 3:
> - **READ side (7 readers, dual-path):** `find_type`/`type_kind`/`type_field_count`/
@@ -346,6 +348,32 @@ when reached (sentinels or accessor fns; see the design doc Risks).
`List` growth; orthogonal, see `current/CHECKPOINT-METATYPE.md`.)
## Log
- **Phase 3 P3.4 step 7 (VM plan) — VM-native metatype CONSTRUCTION: `declare`/`define` + tagged-union `enum_init` (2026-06-18).**
Ported the metatype type-CONSTRUCTION builtins into the VM so the construction examples run
HANDLED end-to-end (no `call_builtin` fallback). Three pieces: (1) **tagged-union `enum_init`
with payload** — the arm previously bailed; now allocates the value (zeroed), writes the tag at
offset 0 (`{ header(tag)@0, [N x i8] payload@tag_size }`, the LLVM `backend/llvm/types.zig`
layout) and copies the payload at `tag_size`. (2) A **`.call_builtin` exec arm** → new
`callBuiltinVm`, the VM-native mirror of the legacy `execBuiltinInner`: `declare(name)` mints an
empty forward nominal slot (shared `declareNominal` helper, also used by `declare_type`);
`define(handle, info)` reads the `TypeInfo` tagged-union VALUE from FLAT MEMORY (tag@0, active
payload `EnumInfo`/`StructInfo`/`TupleInfo` struct at `tag_size`, its single slice field) and
mints via `defineFromInfo`, a faithful port of legacy `defineEnum`/`defineStruct`/`defineTuple`
(all-void enum → real `.@"enum"` per issue 0142, dup-name rejection, `updatePreservingKey` vs
`replaceKeyedInfo`). (3) Refactored the `[]{name,ty}` decode out of `registerTypeVm` into a
shared `decodeMemberSlice` (+ `decodeTypeSlice` for bare-`Type` tuple elements), keyed to the
module-level `NamedMember`. Unmodeled builtins (`type_info`/`type_name`/…) return null → bail
with the builtin name → legacy fallback (dual-path parity). **Correctness guard (caught via
review):** `enum_init`/`define` assume a tag-headed layout, which is WRONG for a `backing_type`
tagged union (laid out as the backing struct) — both now bail loudly on `backing_type != null`
rather than silent-clobber. **Result:** examples `0614`/`0620`/`0621`/`0624`/`0632` run **fully
HANDLED** on the VM (define is the whole eval); `0622`/`0623` run define HANDLED then fall back
cleanly at the still-unported `type_info` reflection. VM output byte-matches legacy for all 7.
**697/0 BOTH gates + all unit tests (added: tagged-union `enum_init` payload layout).** On
`reify`. **Next:** port `type_info` (REFLECT a type → build a `TypeInfo` value in flat memory,
the inverse — reuses the tagged-union `enum_init` write) so `0619`/`0622`/`0623` go fully HANDLED;
then the rest of the comptime corpus (drive the SX_COMPTIME_FLAT_TRACE fallback list toward the
genuinely-non-comptime cases) before the VM-default flip + legacy deletion.
- **Phase 3 P3.4 step 6 (VM plan) — REAL lowering-time Context: allocating + List-building type-fns now run HANDLED on the VM (2026-06-18).**
The VM can now evaluate a comptime type-fn that ALLOCATES at lowering time (the 0141 family) —
the legacy interp cannot. Four changes: (1) `runComptimeTypeFunc` (lower/comptime.zig) FORCES the

View File

@@ -659,6 +659,36 @@ test "comptime_vm exec: payloadless enum_init + enum_tag" {
try std.testing.expectEqual(@as(i64, 11), toI64(try v.run(&fb.func, &.{})));
}
test "comptime_vm exec: tagged-union enum_init with payload lays out {tag@0, payload@tag_size}" {
// The construction primitive `define` reuses: build `E.value(42)` where
// `E = { value: i64, closed: void }` and verify the flat-memory bytes — tag 0
// at offset 0, the i64 payload at offset tag_size (8). Mirrors the LLVM
// `{ header, [N x i8] }` layout the rest of the compiler reads.
const alloc = std.testing.allocator;
var table = types.TypeTable.init(alloc);
defer table.deinit();
const ufields = [_]types.TypeInfo.StructInfo.Field{
.{ .name = table.internString("value"), .ty = .i64 },
.{ .name = table.internString("closed"), .ty = .void },
};
const e = table.intern(.{ .tagged_union = .{ .name = table.internString("E"), .fields = &ufields, .tag_type = .i64 } });
// return E.value(42) → the tagged-union value's Addr
var fb = Fb.init(alloc, &.{}, e);
defer fb.deinit();
const b0 = fb.block(&.{});
const p = fb.add(b0, inst(.{ .const_int = 42 }, .i64));
const g = fb.add(b0, inst(.{ .enum_init = .{ .tag = 0, .payload = ref(p) } }, e));
_ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(g) } }, .void));
var v = vm.Vm.init(alloc);
v.table = &table;
defer v.deinit();
const addr = try v.run(&fb.func, &.{});
try std.testing.expectEqual(@as(u64, 0), try v.machine.readWord(addr, 8)); // tag
try std.testing.expectEqual(@as(u64, 42), try v.machine.readWord(addr + 8, 8)); // payload
}
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);

View File

@@ -256,6 +256,11 @@ fn nominalIdentOf(info: types.TypeInfo) ?struct { name: types.StringId, nominal_
};
}
/// A `{ name: string, ty: Type }` member decoded from flat memory — the shared
/// shape of a compiler-API `Member`, a metatype `EnumVariant { name, payload }`,
/// and a `StructField { name, type }` (all 2-field `{ string, Type }` structs).
const NamedMember = struct { name: types.StringId, ty: TypeId };
/// A signed integer type narrower-or-equal to 64 bits — its loaded bytes must be
/// SIGN-extended into the register (the legacy `.int` model is i64).
fn isSignedInt(ty: TypeId) bool {
@@ -776,11 +781,32 @@ pub const Vm = struct {
// ── Enums (payloadless: the tag is the value) ───────
.enum_init => |ei| {
if (!ei.payload.isNone()) {
self.detail = "comptime VM: enum_init with payload (tagged union) not yet ported";
return error.Unsupported;
}
return .{ .value = @as(Reg, ei.tag) };
if (ei.payload.isNone()) return .{ .value = @as(Reg, ei.tag) };
// Tagged union { tag@0, payload@tag_size } — `{ header, [N x i8] }`
// in the LLVM layout (see backend/llvm/types.zig). Allocate the
// whole value (zeroed: the payload area is max-payload sized, so a
// smaller variant leaves the tail zero), write the tag at offset 0,
// and copy the payload bytes in at `tag_size`.
const table = try self.requireTable();
const uty = ins.ty;
if (uty.isBuiltin() or table.get(uty) != .tagged_union)
return self.failMsg("comptime VM: enum_init-with-payload on a non-tagged-union result type not supported");
const tu = table.get(uty).tagged_union;
// The simple `{ header(tag)@0, [N x i8] payload@tag_size }` layout
// assumed below holds ONLY for a tag_type-headed tagged union. A
// `backing_type` union is laid out as the backing STRUCT (header from
// all-but-last fields, payload = last field) — different offsets — so
// bail loudly rather than write the payload to the wrong place.
if (tu.backing_type != null)
return self.failMsg("comptime VM: enum_init on a backing_type tagged union not yet ported (layout differs)");
const size = table.typeSizeBytes(uty);
const addr = self.machine.allocBytes(size, table.typeAlignBytes(uty));
@memset(try self.machine.bytes(addr, size), 0);
try self.writeField(table, addr, tu.tag_type, @as(Reg, ei.tag));
const tag_size: Addr = @intCast(table.typeSizeBytes(tu.tag_type));
const payload_ty = try self.refTy(ref_types, ei.payload);
try self.writeField(table, addr + tag_size, payload_ty, frame.get(ei.payload.index()));
return .{ .value = addr };
},
.enum_tag => |u| {
const oty = (try self.refTy(ref_types, u.operand));
@@ -846,6 +872,15 @@ pub const Vm = struct {
.ret => |u| return .{ .ret = frame.get(u.operand.index()) },
.ret_void => return .ret_void,
// Comptime metatype `#builtin`s (`declare`/`define`). The VM-native
// mirror of the legacy `execBuiltin` arms; an unmodeled builtin returns
// null → bail with its name → legacy fallback (dual-path parity).
.call_builtin => |bi| {
if (try self.callBuiltinVm(bi, frame, ref_types)) |r| return .{ .value = r };
self.detail = @tagName(bi.builtin);
return error.Unsupported;
},
// Not yet ported (memory, aggregates, calls, …): bail loudly with the
// op name — never a silent default.
else => {
@@ -1166,11 +1201,7 @@ pub const Vm = struct {
if (args.len != 1) return self.failMsg("comptime declare_type: expected (name)");
const s = frame.get(args[0].index()); // string fat-pointer Addr
const text = try self.machine.bytes(try self.sliceData(table, s), @intCast(try self.sliceLen(s)));
const tbl = @constCast(table);
const name_id = tbl.internString(text);
if (tbl.findByName(name_id)) |existing| return @as(Reg, existing.index());
const tid = tbl.internNominal(.{ .tagged_union = .{ .name = name_id, .fields = &.{}, .tag_type = .i64 } }, 0);
return @as(Reg, tid.index());
return @as(Reg, (self.declareNominal(table, text)).index());
}
if (std.mem.eql(u8, name, "pointer_to")) {
if (args.len != 1) return self.failMsg("comptime pointer_to: expected (Type)");
@@ -1193,23 +1224,13 @@ pub const Vm = struct {
const handle = try self.argTypeId(args, frame, 0);
const kind: i64 = @bitCast(frame.get(args[1].index()));
// Decode the `[]Member` slice: element layout comes from the slice's IR
// element type (`Member` — `{ name: string @0, ty: Type @1 }`).
// Decode the `[]Member` slice (element layout `{ name: string, ty: Type }`).
const slice_ty = try self.refTy(ref_types, args[2]);
if (slice_ty.isBuiltin() or table.get(slice_ty) != .slice)
return self.failMsg("comptime register_type: members arg is not a slice");
const member_ty = table.get(slice_ty).slice.element;
if (member_ty.isBuiltin() or table.get(member_ty) != .@"struct" or table.get(member_ty).@"struct".fields.len != 2)
return self.failMsg("comptime register_type: Member element must be a {name, ty} struct");
const mfields = table.get(member_ty).@"struct".fields;
const name_off = fieldOffset(table, member_ty, 0);
const ty_off = fieldOffset(table, member_ty, 1);
const name_fty = mfields[0].ty; // string
const members_word = frame.get(args[2].index());
const len = try self.sliceLen(members_word);
const base = try self.sliceData(table, members_word);
const stride: Addr = @intCast(table.typeSizeBytes(member_ty));
if (len == 0) return self.failMsg("comptime register_type: a type with no members is never valid");
var members = std.ArrayList(NamedMember).empty;
defer members.deinit(self.gpa);
try self.decodeMemberSlice(table, members_word, slice_ty, &members);
if (members.items.len == 0) return self.failMsg("comptime register_type: a type with no members is never valid");
const tbl = @constCast(table);
// The slot's nominal identity — accept the forward `tagged_union` from
@@ -1219,18 +1240,6 @@ pub const Vm = struct {
const ident = nominalIdentOf(table.get(handle)) orelse
return self.failMsg("comptime register_type: handle is not a declare_type'd nominal slot");
const M = struct { name: types.StringId, ty: TypeId };
// Read each `Member { name, ty }` out of flat memory.
var members = std.ArrayList(M).empty;
defer members.deinit(self.gpa);
for (0..@intCast(len)) |i| {
const elem = base + @as(Addr, @intCast(i)) * stride;
const name_fp = try self.readField(table, elem + name_off, name_fty); // string fat-pointer Addr
const mname = try self.machine.bytes(try self.sliceData(table, name_fp), @intCast(try self.sliceLen(name_fp)));
const mty: TypeId = @enumFromInt(@as(u32, @intCast(try self.readField(table, elem + ty_off, .type_value))));
members.append(self.gpa, .{ .name = tbl.internString(mname), .ty = mty }) catch return self.failMsg("comptime register_type: out of memory");
}
switch (kind) {
4 => { // tuple — positional element types (names ignored)
const tys = self.gpa.alloc(TypeId, members.items.len) catch return self.failMsg("comptime register_type: out of memory");
@@ -1263,6 +1272,187 @@ pub const Vm = struct {
return @as(Reg, handle.index());
}
/// Mint (or find) a forward `declare`'d nominal slot named `text`: an empty
/// `tagged_union` placeholder a later `define`/`register_type` completes in
/// place. Idempotent — lowering already registered the named forward slot (so a
/// `*Name` self-reference in the body resolved), so return THAT slot. Shared by
/// the compiler-API `declare_type` and the metatype `declare` builtin.
fn declareNominal(self: *Vm, table: *const types.TypeTable, text: []const u8) TypeId {
_ = self;
const tbl = @constCast(table);
const name_id = tbl.internString(text);
if (tbl.findByName(name_id)) |existing| return existing;
return tbl.internNominal(.{ .tagged_union = .{ .name = name_id, .fields = &.{}, .tag_type = .i64 } }, 0);
}
/// Decode a `[]{ name: string, ty: Type }` slice from flat memory into interned
/// `(StringId, TypeId)` pairs — the shared shape of a compiler-API `Member`, a
/// metatype `EnumVariant { name, payload }`, and a `StructField { name, type }`.
/// `slice_ty` (the slice's IR type) gives the element layout (field offsets +
/// stride). Every malformed shape bails loudly (no silent default).
fn decodeMemberSlice(self: *Vm, table: *const types.TypeTable, slice_word: Reg, slice_ty: TypeId, out: *std.ArrayList(NamedMember)) Error!void {
if (slice_ty.isBuiltin() or table.get(slice_ty) != .slice)
return self.failMsg("comptime define/register: members arg is not a slice");
const member_ty = table.get(slice_ty).slice.element;
if (member_ty.isBuiltin() or table.get(member_ty) != .@"struct" or table.get(member_ty).@"struct".fields.len != 2)
return self.failMsg("comptime define/register: member element must be a {name, ty} struct");
const mfields = table.get(member_ty).@"struct".fields;
const name_off = fieldOffset(table, member_ty, 0);
const ty_off = fieldOffset(table, member_ty, 1);
const name_fty = mfields[0].ty; // string
const len = try self.sliceLen(slice_word);
const base = try self.sliceData(table, slice_word);
const stride: Addr = @intCast(table.typeSizeBytes(member_ty));
const tbl = @constCast(table);
for (0..@intCast(len)) |i| {
const elem = base + @as(Addr, @intCast(i)) * stride;
const name_fp = try self.readField(table, elem + name_off, name_fty); // string fat-pointer Addr
const mname = try self.machine.bytes(try self.sliceData(table, name_fp), @intCast(try self.sliceLen(name_fp)));
const mty: TypeId = @enumFromInt(@as(u32, @intCast(try self.readField(table, elem + ty_off, .type_value))));
out.append(self.gpa, .{ .name = tbl.internString(mname), .ty = mty }) catch return self.failMsg("comptime define/register: out of memory");
}
}
/// Decode a `[]Type` slice (a metatype `TupleInfo.elements` — POSITIONAL, bare
/// `Type` elements with no name) from flat memory into `TypeId`s.
fn decodeTypeSlice(self: *Vm, table: *const types.TypeTable, slice_word: Reg, slice_ty: TypeId, out: *std.ArrayList(TypeId)) Error!void {
if (slice_ty.isBuiltin() or table.get(slice_ty) != .slice)
return self.failMsg("comptime define: tuple elements arg is not a slice");
const elem_ty = table.get(slice_ty).slice.element; // Type (.type_value)
const len = try self.sliceLen(slice_word);
const base = try self.sliceData(table, slice_word);
const stride: Addr = @intCast(table.typeSizeBytes(elem_ty));
for (0..@intCast(len)) |i| {
const e = base + @as(Addr, @intCast(i)) * stride;
const t: TypeId = @enumFromInt(@as(u32, @intCast(try self.readField(table, e, .type_value))));
out.append(self.gpa, t) catch return self.failMsg("comptime define: out of memory");
}
}
/// Service a comptime metatype `#builtin` (`meta.sx`'s `declare`/`define`)
/// natively on flat memory, the VM-native mirror of the legacy
/// `interp.execBuiltinInner` arms. Returns the result word, or `null` for a
/// builtin the VM doesn't model yet (caller bails → legacy fallback, so dual-path
/// parity holds). Keeps BOTH paths alive during the VM-default transition.
fn callBuiltinVm(self: *Vm, bi: inst_mod.BuiltinCall, frame: *Frame, ref_types: []const TypeId) Error!?Reg {
switch (bi.builtin) {
// declare(name) → mint an EMPTY nominal slot, returned as a Type value.
.declare => {
const table = try self.requireTable();
if (bi.args.len != 1) return self.failMsg("comptime declare: expected (name)");
const s = frame.get(bi.args[0].index()); // string fat-pointer Addr
const text = try self.machine.bytes(try self.sliceData(table, s), @intCast(try self.sliceLen(s)));
return @as(Reg, (self.declareNominal(table, text)).index());
},
// define(handle, info) → complete the declared slot from a TypeInfo VALUE.
.define => {
const table = try self.requireTable();
if (bi.args.len != 2) return self.failMsg("comptime define: expected (handle, info)");
const handle = try self.argTypeId(bi.args, frame, 0);
// `info`: a TypeInfo tagged-union value `{ tag@0, payload@tag_size }`.
const info_ty = try self.refTy(ref_types, bi.args[1]);
if (info_ty.isBuiltin() or table.get(info_ty) != .tagged_union)
return self.failMsg("comptime define: info arg is not a TypeInfo tagged union");
const tu = table.get(info_ty).tagged_union;
// The `{ tag@0, payload@tag_size }` read below assumes a tag-headed
// layout (true for `TypeInfo`); a `backing_type` union is laid out
// differently, so bail rather than read the tag from the wrong bytes.
if (tu.backing_type != null)
return self.failMsg("comptime define: info is a backing_type tagged union (unexpected layout)");
const info_addr = frame.get(bi.args[1].index());
const tag_size: Addr = @intCast(table.typeSizeBytes(tu.tag_type));
const tag = try self.machine.readWord(info_addr, tag_size);
if (tag >= tu.fields.len) return self.failMsg("comptime define: TypeInfo tag out of range");
// The active payload (EnumInfo / StructInfo / TupleInfo) is a struct
// holding ONE slice field; its bytes live at `info_addr + tag_size`.
const payload_ty = tu.fields[@intCast(tag)].ty;
if (payload_ty.isBuiltin() or table.get(payload_ty) != .@"struct" or table.get(payload_ty).@"struct".fields.len != 1)
return self.failMsg("comptime define: TypeInfo payload is not a single-slice info struct");
return try self.defineFromInfo(table, handle, @intCast(tag), payload_ty, info_addr + tag_size);
},
else => return null, // not modeled on the VM yet → caller bails to legacy
}
}
/// Complete a `declare()`d slot from a decoded `TypeInfo` (the VM-native mirror
/// of `interp.defineType` → `defineEnum`/`defineStruct`/`defineTuple`). `tag` is
/// the TypeInfo variant index (meta.sx order: 0 `enum`, 1 `struct`, 2 `tuple`);
/// `payload_ty`/`payload_addr` locate the active info struct (one slice field).
/// Mirrors the legacy minting exactly (all-void enum → real `.@"enum"`, dup-name
/// rejection, `updatePreservingKey` vs `replaceKeyedInfo`) so the result is
/// byte-identical and the dual paths can't drift. Mutates the table LAST (after
/// decoding succeeds) so a mid-decode bail leaves the slot untouched — parity
/// with the legacy "no mutation before the bail".
fn defineFromInfo(self: *Vm, table: *const types.TypeTable, handle: TypeId, tag: u32, payload_ty: TypeId, payload_addr: Addr) Error!Reg {
const tbl = @constCast(table);
const cur = table.get(handle);
const ident = nominalIdentOf(cur) orelse
return self.failMsg("comptime define: handle is not a declare()'d nominal slot");
if (cur != .tagged_union) return self.failMsg("comptime define: handle is not a declare()'d slot");
// The info struct's single field is the member/element slice; read its
// fat-pointer (embedded at field-0 offset within the info struct).
const slice_field_ty = table.get(payload_ty).@"struct".fields[0].ty;
const slice_word = try self.readField(table, payload_addr + fieldOffset(table, payload_ty, 0), slice_field_ty);
switch (tag) {
0 => { // .enum(EnumInfo{ variants: []EnumVariant{name, payload} })
var members = std.ArrayList(NamedMember).empty;
defer members.deinit(self.gpa);
try self.decodeMemberSlice(table, slice_word, slice_field_ty, &members);
if (members.items.len == 0) return self.failMsg("comptime define: enum has no variants");
// A FULLY payloadless variant set (every payload `void`) is an actual
// `.@"enum"` (a kind change → `replaceKeyedInfo`); minting it as an
// all-void tagged_union trips `verifySizes` at codegen (issue 0142).
var all_void = true;
for (members.items) |m| if (m.ty != .void) {
all_void = false;
break;
};
if (all_void) {
const names = self.gpa.alloc(types.StringId, members.items.len) catch return self.failMsg("comptime define: out of memory");
for (members.items, 0..) |m, i| {
for (names[0..i]) |prev| if (prev == m.name) return self.failMsg("comptime define: duplicate variant name");
names[i] = m.name;
}
tbl.replaceKeyedInfo(handle, .{ .@"enum" = .{ .name = ident.name, .variants = names, .nominal_id = ident.nominal_id } });
} else {
const flds = self.gpa.alloc(types.TypeInfo.StructInfo.Field, members.items.len) catch return self.failMsg("comptime define: out of memory");
for (members.items, 0..) |m, i| {
for (flds[0..i]) |prev| if (prev.name == m.name) return self.failMsg("comptime define: duplicate variant name");
flds[i] = .{ .name = m.name, .ty = m.ty };
}
// Name/id unchanged → still a tagged_union → stable key.
tbl.updatePreservingKey(handle, .{ .tagged_union = .{ .name = ident.name, .fields = flds, .tag_type = .i64, .nominal_id = ident.nominal_id } });
}
},
1 => { // .struct(StructInfo{ fields: []StructField{name, type} })
var members = std.ArrayList(NamedMember).empty;
defer members.deinit(self.gpa);
try self.decodeMemberSlice(table, slice_word, slice_field_ty, &members);
if (members.items.len == 0) return self.failMsg("comptime define: struct has no fields");
const flds = self.gpa.alloc(types.TypeInfo.StructInfo.Field, members.items.len) catch return self.failMsg("comptime define: out of memory");
for (members.items, 0..) |m, i| {
for (flds[0..i]) |prev| if (prev.name == m.name) return self.failMsg("comptime define: duplicate field name");
flds[i] = .{ .name = m.name, .ty = m.ty };
}
// tagged_union slot → struct is a kind change → `replaceKeyedInfo`.
tbl.replaceKeyedInfo(handle, .{ .@"struct" = .{ .name = ident.name, .fields = flds, .nominal_id = ident.nominal_id } });
},
2 => { // .tuple(TupleInfo{ elements: []Type }) — positional, no names
var elems = std.ArrayList(TypeId).empty;
defer elems.deinit(self.gpa);
try self.decodeTypeSlice(table, slice_word, slice_field_ty, &elems);
if (elems.items.len == 0) return self.failMsg("comptime define: tuple has no elements");
const tys = self.gpa.alloc(TypeId, elems.items.len) catch return self.failMsg("comptime define: out of memory");
@memcpy(tys, elems.items);
tbl.replaceKeyedInfo(handle, .{ .tuple = .{ .fields = tys, .names = null } });
},
else => return self.failMsg("comptime define: unknown TypeInfo variant"),
}
return @as(Reg, handle.index());
}
// ── Reg ↔ Value bridge (legacy-interop boundary) ────────────────────────
//
// The wiring step routes a comptime eval through the VM, falling back to the