diff --git a/current/CHECKPOINT-COMPILER-API.md b/current/CHECKPOINT-COMPILER-API.md index 1bbb73a5..8dac2f78 100644 --- a/current/CHECKPOINT-COMPILER-API.md +++ b/current/CHECKPOINT-COMPILER-API.md @@ -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 20–99 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 diff --git a/src/ir/comptime_vm.test.zig b/src/ir/comptime_vm.test.zig index 5957a1ef..c797e54e 100644 --- a/src/ir/comptime_vm.test.zig +++ b/src/ir/comptime_vm.test.zig @@ -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); diff --git a/src/ir/comptime_vm.zig b/src/ir/comptime_vm.zig index a793162b..b4a6adfd 100644 --- a/src/ir/comptime_vm.zig +++ b/src/ir/comptime_vm.zig @@ -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