From 2fb594748caeb20eeff4f587954bcd34b07f9cbf Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Sat, 16 May 2026 12:20:56 +0900 Subject: [PATCH] cstage+selfhost+test: principled identity-cast skip (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes b5632b1's single-site dst_is_enum gate. Skip the narrow- clamp MOVL when src.width == dst.width && src.signed == dst.signed. Closes #25's followup. Both stages need symmetric source-type derivation for byte-id. cstage deliberately throws away the checker's richer typed-AST and uses a structural walker (castsrcprim) that mirrors wwstage's exprprimresolved case-for-case. Otherwise cstage's `.len: i32` resolves to i32 (skip) while wwstage's misses the pseudo-field (clamp) — bootstrap diverges. Pseudo-fields, N_BIN, N_INDEX, N_CALL, match-bindings all yield sz=0 → clamp emits defensively on both. The N_TENUM walker now follows enum aliases in wwstage's typenodeprimresolved (was the original lacuna behind #25), and bool is excluded early in the same helper (mirrors cstage's type_isint(TY_BOOL)=false). bool→bool keeps its dedicated is_bool ANDQ $255 emit; bool→i8 / bool→u8 etc. fall through to the clamp on both stages. Walker shape (cstage castsrcprim / wwstage exprprimresolved): N_INTLIT → tsuffix gated, untyped excluded N_IDENT → trust local's resolved tnode N_CAST → recurse on declared dst N_UN → recurse on operand N_DOT → real-struct only (TY_STRUCT or TY_PTR→TY_STRUCT) others → sz=0 → identity false → clamp emits Test 710 grew from 5 → 16 rows: 6 identity-width pins (u32/i32/u8/ i8/u16/i16 self), 1 sign-change pin (u32→i32 clamp MUST fire), 2 silent-miscompile exit-validating rows (truncate via divide), 1 pseudo-field defensive pin (`s.len: i32`), 1 bool-source pin (`b: i8`). Asm byte-id asserted on every row. Out of scope: redundant clamps remain for patterns wwstage can't structurally derive (N_BIN, N_CALL, N_INDEX, pseudo-fields). A sibling task extending wwstage's type inference closes those. --- cmd/w6c/cgen.c | 124 ++++++++-- selfhost/cmd/w6c/main.combined.ww | 210 +++++++++++++---- selfhost/cmd/wcc/cgenexpr.ww | 114 ++++++---- selfhost/cmd/wcc/cgenutil.ww | 96 ++++++++ selfhost/cmd/wwdump/main.combined.ww | 210 +++++++++++++---- test/wcc/710_cast_enum_movl.c | 323 +++++++++++++++++++++------ 6 files changed, 860 insertions(+), 217 deletions(-) diff --git a/cmd/w6c/cgen.c b/cmd/w6c/cgen.c index 7e6be785..4d2d9284 100644 --- a/cmd/w6c/cgen.c +++ b/cmd/w6c/cgen.c @@ -168,6 +168,84 @@ fldstoreop(Type *t, int sz) return A_MOVQ; } +/* castsrcprim — structural (size, unsigned) of an N_CAST's source + * expression, mirroring wwstage's exprprimresolved in + * selfhost/cmd/wcc/cgenutil.ww. The cgen-stage match has to be + * structural, not "use n->type": cstage's checker decorates every + * node with a precise Type, but wwstage has no checker and must + * derive the source type from the AST shape. To keep cstage and + * wwstage emitting byte-identical asm under the #33 identity-width + * identity-sign clamp-skip, both must agree on what a "knowable + * source type" is. The shape menu: + * N_INTLIT — typed literal (`7u32`) via tsuffix. + * N_IDENT, N_CAST — type set by checker; trust it. Wwstage + * reaches the same answer via localfindnode + + * typenodeprimresolved (alias / enum walk) + * and via the cast's rhs type-node. + * N_UN — recurse on operand. + * N_DOT real field — base resolves to TY_STRUCT (or ptr-to); + * use the field's checker-set type. Pseudo- + * fields .len/.cap/.ptr are excluded — they + * are i32 / *T but wwstage's exprprimresolved + * doesn't recognise them, and asymmetry there + * breaks 995_self_rebuild. Tuple positional + * access likewise excluded. + * default — sz=0, identity check fails, clamp emits. + * Matches wwstage's conservative fallback. */ +static void +castsrcprim(Node *n, int *sz, int *unsignd) +{ + *sz = 0; + *unsignd = 0; + if (n == NULL) return; + Type *t = NULL; + switch (n->kind) { + case N_INTLIT: + /* tsuffix-typed literal: checker resolved n->type via + * lookup_builtin. Untyped int leaves n->type at + * TY_UNTYPED_INT — we conservatively skip those (wwstage + * matches: no tsuffix → sz=0). */ + if (n->tsuffix && n->type) { + Type *u = (n->type->kind == TY_NAMED) + ? n->type->under : n->type; + if (u && u->kind != TY_UNTYPED_INT + && u->kind != TY_UNTYPED_RUNE + && type_isint(u)) { + t = u; + } + } + break; + case N_IDENT: + case N_CAST: + t = n->type; + break; + case N_UN: + castsrcprim(n->lhs, sz, unsignd); + return; + case N_DOT: { + /* Real struct field only. .len / .cap / .ptr on str / + * slice / array are pseudo-fields wwstage doesn't see. */ + Type *bt = n->lhs ? n->lhs->type : NULL; + Type *bu = (bt && bt->kind == TY_NAMED) ? bt->under : bt; + if (bu && bu->kind == TY_PTR) { + Type *st = bu->sub; + bu = (st && st->kind == TY_NAMED) ? st->under : st; + } + if (bu && bu->kind == TY_STRUCT) { + t = n->type; + } + break; + } + default: + break; + } + Type *u = (t && t->kind == TY_NAMED) ? t->under : t; + if (u && type_isint(u)) { + *sz = (int)u->size; + *unsignd = type_isunsigned(u); + } +} + /* localloadop — read instruction for a scalar local/let load. Same * dispatch as fldloadop, but keyed on the value's own type. Lets the * caller emit MOVSXD / MOVSWQ / MOVSBQ on a signed-narrow slot instead @@ -4793,20 +4871,40 @@ cgexpr(Cg *c, Node *n, Local *locals) if (!from_f && !to_f && n->type) { Type *tt = n->type; Type *tu = (tt && tt->kind == TY_NAMED) ? tt->under : tt; - /* Wwstage's cgcast walker only steps through N_TBANG / - * N_TNAME alias links; an enum's `aliaslookup` returns - * the N_TENUM body, which breaks the loop and skips the - * clamp. Mirror that here so cstage agrees byte-for-byte - * on a u32→enum-u32 cast (task #25). Predicate recursion - * through TY_ENUM in `type_isint`/`type_isunsigned` - * stays — other call sites rely on it; only this site - * gates on TY_ENUM explicitly. Skip is by dst kind only: - * `enum-u32 → u32` still emits the clamp, matching - * wwstage's asymmetry. (See followup: identity-width - * identity-sign hygiene across both stages.) */ - int dst_is_enum = tu && tu->kind == TY_ENUM; + /* Identity-width identity-sign cast is a no-op at the + * machine-int level: src and dst share both width and + * signedness, so the natural slot/load already carries + * the right canonical 64-bit shape and the narrow-clamp + * is dead. Replaces b5632b1's single-site `!dst_is_enum` + * gate (task #25) which mirrored wwstage's N_TENUM + * lacuna; the lacuna is fixed there too, so this gate + * stays symmetric across both stages (#33). Source side + * uses `castsrcprim` (a structural walk matching + * wwstage's exprprimresolved exactly), NOT n->lhs->type + * — cstage's checker has richer type info than wwstage + * can derive without a checker, and the asymmetric + * coverage broke 995_self_rebuild's byte-id. The cost + * is that some casts (`.len: i32`, N_BIN result, call + * return, match-bound payload) still emit a redundant + * clamp on both stages; closing those gaps is a + * sibling task that extends wwstage's type inference. + * Incidentally fixes a silent miscompile #25's + * dst-kind-only skip left in place: u32→enum-u8 (and + * similar narrow-to-enum casts) was suppressing the + * clamp, so the upper bits of the source value leaked + * through register-chained downstream uses. Caveat: + * removing the defensive MOVL exposes any upstream + * cgen path that leaves garbage in upper RAX when + * producing a sub-word value — the contract is + * producers leave the value in canonical width- + * extended form. */ + int src_w = 0, src_unsignd = 0; + castsrcprim(n->lhs, &src_w, &src_unsignd); + int dst_w = (tu && type_isint(tu)) ? (int)tu->size : 0; + int identity = dst_w > 0 && src_w == dst_w + && src_unsignd == type_isunsigned(tu); if (tu && type_isint(tu) && tu->size > 0 - && tu->size < 8 && !dst_is_enum) { + && tu->size < 8 && !identity) { if (type_isunsigned(tu)) { if (tu->size == 4) { ins2(c, A_MOVL, diff --git a/selfhost/cmd/w6c/main.combined.ww b/selfhost/cmd/w6c/main.combined.ww index a7e34c62..f0158582 100644 --- a/selfhost/cmd/w6c/main.combined.ww +++ b/selfhost/cmd/w6c/main.combined.ww @@ -7537,6 +7537,102 @@ fn primsize(name: str) i32 = { return 0; }; +// typenodeprimresolved — walk N_TBANG / N_TENUM / N_TNAME alias +// chains to the underlying primitive, returning its byte size and +// signedness. Sets *sz_out = 0 when the type doesn't reduce to a +// width-known primitive (composite, unresolved name, default-storage +// enum, etc.). Mirrors cstage's `type_isint(t) ? t->size : 0` / +// `type_isunsigned` recursion through TY_NAMED and TY_ENUM. Used by +// cgcast's identity-width identity-sign clamp-skip predicate (#33). +export fn typenodeprimresolved(c: *cgen, t: *node, + sz_out: *i32, unsigned_out: *bool) void = { + *sz_out = 0; + *unsigned_out = false; + let cur: *node = t; + for (cur != nil) { + let k: nkind = cur.kind; + if (k == nkind.N_TBANG) { cur = cur.lhs; } + else { if (k == nkind.N_TENUM) { cur = cur.lhs; } + else { if (k == nkind.N_TNAME) { + let nm: str = cur.str; + // bool is excluded from the int-prim contract: cstage's + // `type_isint(TY_BOOL)` is false, so its identity check + // leaves src_w=0 on a bool source. Match that here so a + // `let y: i8 = b: i8;` (bool b) doesn't fire identity in + // wwstage and skip the MOVSBQ that cstage emits. Other + // call sites (slot sizing, etc.) still want + // primsize("bool")=1, so the exclusion stays local. The + // dedicated `is_bool` path in cgcast owns bool→bool's + // ANDQ $255 on both stages. + if (streq(nm, "bool")) { return; }; + let ps: i32 = primsize(nm); + if (ps > 0) { + *sz_out = ps; + *unsigned_out = typenameisunsigned(nm); + return; + }; + let al: *node = aliaslookup(c, nm); + if (al == nil) { return; }; + cur = al; + } + else { return; }; }; }; + }; +}; + +// exprprimresolved — best-effort static (primsize, signedness) for an +// expression. Used by cgcast (#33) to derive the source-side primitive +// width and signedness so the identity-width identity-sign clamp-skip +// predicate fires. Sets *sz_out = 0 when the type can't be derived +// (untyped literal, call result with no return-type lookup, etc.); +// caller treats sz=0 as "not identity", which conservatively keeps +// the clamp. Mirror of cstage's `n->lhs->type` lookup with the same +// TY_NAMED / TY_ENUM recursion through type_isint / type_isunsigned. +export fn exprprimresolved(c: *cgen, n: *node, + sz_out: *i32, unsigned_out: *bool) void = { + *sz_out = 0; + *unsigned_out = false; + if (n == nil) { return; }; + let k: nkind = n.kind; + if (k == nkind.N_INTLIT) { + // Typed-int literal: `7u32` has tsuffix = "u32". Mirrors + // cstage's `cexpr` which assigns `lookup_builtin(tsuffix)` + // as the node's type — without this, wwstage misses the + // suffix and emits a defensive clamp where cstage skips, + // breaking byte-id on rows like `let y: mymode = 7u32: + // mymode;` (mymode = enum u32). + let s: str = n.tsuffix; + if (s.len > 0) { + let ps: i32 = primsize(s); + if (ps > 0) { + *sz_out = ps; + *unsigned_out = typenameisunsigned(s); + }; + }; + return; + }; + if (k == nkind.N_IDENT) { + let lc: *local = localfindnode(c, n.str); + if (lc != nil) { + typenodeprimresolved(c, lc.tnode, + sz_out, unsigned_out); + }; + return; + }; + if (k == nkind.N_CAST) { + typenodeprimresolved(c, n.rhs, sz_out, unsigned_out); + return; + }; + if (k == nkind.N_UN) { + exprprimresolved(c, n.lhs, sz_out, unsigned_out); + return; + }; + if (k == nkind.N_DOT) { + typenodeprimresolved(c, dotfieldtnode(c, n), + sz_out, unsigned_out); + return; + }; +}; + // variantnamematch — tagged-union variant names are compared as if // they'd been alias-resolved. Pattern names can be module-qualified // (`strconv.invalid` from a `case let e: strconv.invalid =>`), @@ -9824,55 +9920,79 @@ fn cgcast(c: *cgen, n: *node) void = { // Mirrors cmd/w6c/cgen.c's N_CAST clamp. Unsigned narrow clears // the upper bits via MOVL/ANDQ; signed narrow sign-extends via // MOVSBQ/MOVSWQ/MOVSXD reg-reg so the sign bit propagates. + // + // Identity-width identity-sign cast is a no-op at the machine- + // int level: src and dst share both width and signedness, so the + // natural slot/load already carries the right canonical 64-bit + // shape. Skip the clamp in that case. Symmetric with cstage's + // principled gate (#33). Replaces the previous N_TENUM lacuna in + // this walker (the alias-step missed `N_TENUM`, so any cast to + // an enum dst landed on tn==nil and skipped the clamp by + // accident — task #25 mirrored that into cstage as a single-site + // gate, and #33 retires both). The walker now follows N_TENUM + // too so a narrow-to-enum cast (u32→enum-u8, i64→enum-i32) + // resolves to the underlying primitive and the clamp fires — + // fixing a silent miscompile in the process. if (srcfk == 0 && dstfk == 0) { - let tn: *node = n.rhs; - // Walk through alias chains (`type random = u64`) and the - // `!T` error-flag wrapper (`type invalid = !i32`) — the - // bang is a tagged-union marker, not a representational - // change, so it must not block the narrow-cast clamp. - for (tn != nil) { - if (tn.kind == nkind.N_TBANG) { tn = tn.lhs; } - else { if (tn.kind != nkind.N_TNAME) { tn = nil; } - else { - let nm: str = tn.str; - if (primsize(nm) > 0) { break; }; - let alias: *node = aliaslookup(c, nm); - if (alias == nil) { tn = nil; } - else { tn = alias; }; - }; }; + let sz: i32 = 0; + let is_unsigned: bool = false; + typenodeprimresolved(c, n.rhs, &sz, &is_unsigned); + let src_sz: i32 = 0; + let src_unsigned: bool = false; + exprprimresolved(c, n.lhs, &src_sz, &src_unsigned); + let identity: bool = false; + if (sz > 0) { if (src_sz == sz) { + if (src_unsigned == is_unsigned) { identity = true; }; + }; }; + // Detect bool dst by walking n.rhs to the leaf TNAME. bool + // keeps its dedicated ANDQ $255 contract regardless of + // upstream shape; it stays off the identity path. + let leaf_tn: *node = n.rhs; + for (leaf_tn != nil) { + let lk: nkind = leaf_tn.kind; + if (lk == nkind.N_TBANG) { leaf_tn = leaf_tn.lhs; } + else { if (lk == nkind.N_TENUM) { leaf_tn = leaf_tn.lhs; } + else { if (lk == nkind.N_TNAME) { + let lnm: str = leaf_tn.str; + if (primsize(lnm) > 0) { break; }; + let lal: *node = aliaslookup(c, lnm); + if (lal == nil) { leaf_tn = nil; } + else { leaf_tn = lal; }; + } + else { leaf_tn = nil; }; }; }; }; - if (tn != nil) { - let nm: str = tn.str; - let sz: i32 = primsize(nm); - let is_unsigned: bool = typenameisunsigned(nm); - let is_bool: bool = streq(nm, "bool"); - // Symmetric narrow on signed vs unsigned (task #5): - // unsigned (incl. rune) clears upper bits; signed - // sign-extends. bool is size 1 but neither — falls - // through to its dedicated ANDQ $255 below. - if (sz > 0) { if (sz < 8) { if (!is_bool) { - if (is_unsigned) { - if (sz == 4) { - emitline("\tMOVL\tAX, AX\n"); - } else { - let mask: i64 = 0xFFi64; - if (sz == 2) { mask = 0xFFFFi64; }; - emitline("\tANDQ\t$"); - emitint(mask); - emitline(", AX\n"); - }; + let is_bool: bool = false; + if (leaf_tn != nil) { + if (leaf_tn.kind == nkind.N_TNAME) { + is_bool = streq(leaf_tn.str, "bool"); + }; + }; + // Symmetric narrow on signed vs unsigned (task #5): + // unsigned (incl. rune) clears upper bits; signed + // sign-extends. bool is size 1 but neither — falls + // through to its dedicated ANDQ $255 below. + if (sz > 0) { if (sz < 8) { if (!is_bool) { if (!identity) { + if (is_unsigned) { + if (sz == 4) { + emitline("\tMOVL\tAX, AX\n"); } else { - if (sz == 1) { - emitline("\tMOVSBQ\tAX, AX\n"); - } else { if (sz == 2) { - emitline("\tMOVSWQ\tAX, AX\n"); - } else { if (sz == 4) { - emitline("\tMOVSXD\tAX, AX\n"); - }; }; }; + let mask: i64 = 0xFFi64; + if (sz == 2) { mask = 0xFFFFi64; }; + emitline("\tANDQ\t$"); + emitint(mask); + emitline(", AX\n"); }; - }; }; }; - if (is_bool) { emitline("\tANDQ\t$255, AX\n"); }; - }; + } else { + if (sz == 1) { + emitline("\tMOVSBQ\tAX, AX\n"); + } else { if (sz == 2) { + emitline("\tMOVSWQ\tAX, AX\n"); + } else { if (sz == 4) { + emitline("\tMOVSXD\tAX, AX\n"); + }; }; }; + }; + }; }; }; }; + if (is_bool) { emitline("\tANDQ\t$255, AX\n"); }; return; }; if (srcfk == 0 && dstfk == 2) { diff --git a/selfhost/cmd/wcc/cgenexpr.ww b/selfhost/cmd/wcc/cgenexpr.ww index 43ef6dee..1c6a5e7d 100644 --- a/selfhost/cmd/wcc/cgenexpr.ww +++ b/selfhost/cmd/wcc/cgenexpr.ww @@ -381,55 +381,79 @@ fn cgcast(c: *cgen, n: *node) void = { // Mirrors cmd/w6c/cgen.c's N_CAST clamp. Unsigned narrow clears // the upper bits via MOVL/ANDQ; signed narrow sign-extends via // MOVSBQ/MOVSWQ/MOVSXD reg-reg so the sign bit propagates. + // + // Identity-width identity-sign cast is a no-op at the machine- + // int level: src and dst share both width and signedness, so the + // natural slot/load already carries the right canonical 64-bit + // shape. Skip the clamp in that case. Symmetric with cstage's + // principled gate (#33). Replaces the previous N_TENUM lacuna in + // this walker (the alias-step missed `N_TENUM`, so any cast to + // an enum dst landed on tn==nil and skipped the clamp by + // accident — task #25 mirrored that into cstage as a single-site + // gate, and #33 retires both). The walker now follows N_TENUM + // too so a narrow-to-enum cast (u32→enum-u8, i64→enum-i32) + // resolves to the underlying primitive and the clamp fires — + // fixing a silent miscompile in the process. if (srcfk == 0 && dstfk == 0) { - let tn: *node = n.rhs; - // Walk through alias chains (`type random = u64`) and the - // `!T` error-flag wrapper (`type invalid = !i32`) — the - // bang is a tagged-union marker, not a representational - // change, so it must not block the narrow-cast clamp. - for (tn != nil) { - if (tn.kind == nkind.N_TBANG) { tn = tn.lhs; } - else { if (tn.kind != nkind.N_TNAME) { tn = nil; } - else { - let nm: str = tn.str; - if (primsize(nm) > 0) { break; }; - let alias: *node = aliaslookup(c, nm); - if (alias == nil) { tn = nil; } - else { tn = alias; }; - }; }; + let sz: i32 = 0; + let is_unsigned: bool = false; + typenodeprimresolved(c, n.rhs, &sz, &is_unsigned); + let src_sz: i32 = 0; + let src_unsigned: bool = false; + exprprimresolved(c, n.lhs, &src_sz, &src_unsigned); + let identity: bool = false; + if (sz > 0) { if (src_sz == sz) { + if (src_unsigned == is_unsigned) { identity = true; }; + }; }; + // Detect bool dst by walking n.rhs to the leaf TNAME. bool + // keeps its dedicated ANDQ $255 contract regardless of + // upstream shape; it stays off the identity path. + let leaf_tn: *node = n.rhs; + for (leaf_tn != nil) { + let lk: nkind = leaf_tn.kind; + if (lk == nkind.N_TBANG) { leaf_tn = leaf_tn.lhs; } + else { if (lk == nkind.N_TENUM) { leaf_tn = leaf_tn.lhs; } + else { if (lk == nkind.N_TNAME) { + let lnm: str = leaf_tn.str; + if (primsize(lnm) > 0) { break; }; + let lal: *node = aliaslookup(c, lnm); + if (lal == nil) { leaf_tn = nil; } + else { leaf_tn = lal; }; + } + else { leaf_tn = nil; }; }; }; }; - if (tn != nil) { - let nm: str = tn.str; - let sz: i32 = primsize(nm); - let is_unsigned: bool = typenameisunsigned(nm); - let is_bool: bool = streq(nm, "bool"); - // Symmetric narrow on signed vs unsigned (task #5): - // unsigned (incl. rune) clears upper bits; signed - // sign-extends. bool is size 1 but neither — falls - // through to its dedicated ANDQ $255 below. - if (sz > 0) { if (sz < 8) { if (!is_bool) { - if (is_unsigned) { - if (sz == 4) { - emitline("\tMOVL\tAX, AX\n"); - } else { - let mask: i64 = 0xFFi64; - if (sz == 2) { mask = 0xFFFFi64; }; - emitline("\tANDQ\t$"); - emitint(mask); - emitline(", AX\n"); - }; + let is_bool: bool = false; + if (leaf_tn != nil) { + if (leaf_tn.kind == nkind.N_TNAME) { + is_bool = streq(leaf_tn.str, "bool"); + }; + }; + // Symmetric narrow on signed vs unsigned (task #5): + // unsigned (incl. rune) clears upper bits; signed + // sign-extends. bool is size 1 but neither — falls + // through to its dedicated ANDQ $255 below. + if (sz > 0) { if (sz < 8) { if (!is_bool) { if (!identity) { + if (is_unsigned) { + if (sz == 4) { + emitline("\tMOVL\tAX, AX\n"); } else { - if (sz == 1) { - emitline("\tMOVSBQ\tAX, AX\n"); - } else { if (sz == 2) { - emitline("\tMOVSWQ\tAX, AX\n"); - } else { if (sz == 4) { - emitline("\tMOVSXD\tAX, AX\n"); - }; }; }; + let mask: i64 = 0xFFi64; + if (sz == 2) { mask = 0xFFFFi64; }; + emitline("\tANDQ\t$"); + emitint(mask); + emitline(", AX\n"); }; - }; }; }; - if (is_bool) { emitline("\tANDQ\t$255, AX\n"); }; - }; + } else { + if (sz == 1) { + emitline("\tMOVSBQ\tAX, AX\n"); + } else { if (sz == 2) { + emitline("\tMOVSWQ\tAX, AX\n"); + } else { if (sz == 4) { + emitline("\tMOVSXD\tAX, AX\n"); + }; }; }; + }; + }; }; }; }; + if (is_bool) { emitline("\tANDQ\t$255, AX\n"); }; return; }; if (srcfk == 0 && dstfk == 2) { diff --git a/selfhost/cmd/wcc/cgenutil.ww b/selfhost/cmd/wcc/cgenutil.ww index 17f3fa97..841f7f5f 100644 --- a/selfhost/cmd/wcc/cgenutil.ww +++ b/selfhost/cmd/wcc/cgenutil.ww @@ -1294,6 +1294,102 @@ fn primsize(name: str) i32 = { return 0; }; +// typenodeprimresolved — walk N_TBANG / N_TENUM / N_TNAME alias +// chains to the underlying primitive, returning its byte size and +// signedness. Sets *sz_out = 0 when the type doesn't reduce to a +// width-known primitive (composite, unresolved name, default-storage +// enum, etc.). Mirrors cstage's `type_isint(t) ? t->size : 0` / +// `type_isunsigned` recursion through TY_NAMED and TY_ENUM. Used by +// cgcast's identity-width identity-sign clamp-skip predicate (#33). +export fn typenodeprimresolved(c: *cgen, t: *node, + sz_out: *i32, unsigned_out: *bool) void = { + *sz_out = 0; + *unsigned_out = false; + let cur: *node = t; + for (cur != nil) { + let k: nkind = cur.kind; + if (k == nkind.N_TBANG) { cur = cur.lhs; } + else { if (k == nkind.N_TENUM) { cur = cur.lhs; } + else { if (k == nkind.N_TNAME) { + let nm: str = cur.str; + // bool is excluded from the int-prim contract: cstage's + // `type_isint(TY_BOOL)` is false, so its identity check + // leaves src_w=0 on a bool source. Match that here so a + // `let y: i8 = b: i8;` (bool b) doesn't fire identity in + // wwstage and skip the MOVSBQ that cstage emits. Other + // call sites (slot sizing, etc.) still want + // primsize("bool")=1, so the exclusion stays local. The + // dedicated `is_bool` path in cgcast owns bool→bool's + // ANDQ $255 on both stages. + if (streq(nm, "bool")) { return; }; + let ps: i32 = primsize(nm); + if (ps > 0) { + *sz_out = ps; + *unsigned_out = typenameisunsigned(nm); + return; + }; + let al: *node = aliaslookup(c, nm); + if (al == nil) { return; }; + cur = al; + } + else { return; }; }; }; + }; +}; + +// exprprimresolved — best-effort static (primsize, signedness) for an +// expression. Used by cgcast (#33) to derive the source-side primitive +// width and signedness so the identity-width identity-sign clamp-skip +// predicate fires. Sets *sz_out = 0 when the type can't be derived +// (untyped literal, call result with no return-type lookup, etc.); +// caller treats sz=0 as "not identity", which conservatively keeps +// the clamp. Mirror of cstage's `n->lhs->type` lookup with the same +// TY_NAMED / TY_ENUM recursion through type_isint / type_isunsigned. +export fn exprprimresolved(c: *cgen, n: *node, + sz_out: *i32, unsigned_out: *bool) void = { + *sz_out = 0; + *unsigned_out = false; + if (n == nil) { return; }; + let k: nkind = n.kind; + if (k == nkind.N_INTLIT) { + // Typed-int literal: `7u32` has tsuffix = "u32". Mirrors + // cstage's `cexpr` which assigns `lookup_builtin(tsuffix)` + // as the node's type — without this, wwstage misses the + // suffix and emits a defensive clamp where cstage skips, + // breaking byte-id on rows like `let y: mymode = 7u32: + // mymode;` (mymode = enum u32). + let s: str = n.tsuffix; + if (s.len > 0) { + let ps: i32 = primsize(s); + if (ps > 0) { + *sz_out = ps; + *unsigned_out = typenameisunsigned(s); + }; + }; + return; + }; + if (k == nkind.N_IDENT) { + let lc: *local = localfindnode(c, n.str); + if (lc != nil) { + typenodeprimresolved(c, lc.tnode, + sz_out, unsigned_out); + }; + return; + }; + if (k == nkind.N_CAST) { + typenodeprimresolved(c, n.rhs, sz_out, unsigned_out); + return; + }; + if (k == nkind.N_UN) { + exprprimresolved(c, n.lhs, sz_out, unsigned_out); + return; + }; + if (k == nkind.N_DOT) { + typenodeprimresolved(c, dotfieldtnode(c, n), + sz_out, unsigned_out); + return; + }; +}; + // variantnamematch — tagged-union variant names are compared as if // they'd been alias-resolved. Pattern names can be module-qualified // (`strconv.invalid` from a `case let e: strconv.invalid =>`), diff --git a/selfhost/cmd/wwdump/main.combined.ww b/selfhost/cmd/wwdump/main.combined.ww index a5d49984..1743b780 100644 --- a/selfhost/cmd/wwdump/main.combined.ww +++ b/selfhost/cmd/wwdump/main.combined.ww @@ -7537,6 +7537,102 @@ fn primsize(name: str) i32 = { return 0; }; +// typenodeprimresolved — walk N_TBANG / N_TENUM / N_TNAME alias +// chains to the underlying primitive, returning its byte size and +// signedness. Sets *sz_out = 0 when the type doesn't reduce to a +// width-known primitive (composite, unresolved name, default-storage +// enum, etc.). Mirrors cstage's `type_isint(t) ? t->size : 0` / +// `type_isunsigned` recursion through TY_NAMED and TY_ENUM. Used by +// cgcast's identity-width identity-sign clamp-skip predicate (#33). +export fn typenodeprimresolved(c: *cgen, t: *node, + sz_out: *i32, unsigned_out: *bool) void = { + *sz_out = 0; + *unsigned_out = false; + let cur: *node = t; + for (cur != nil) { + let k: nkind = cur.kind; + if (k == nkind.N_TBANG) { cur = cur.lhs; } + else { if (k == nkind.N_TENUM) { cur = cur.lhs; } + else { if (k == nkind.N_TNAME) { + let nm: str = cur.str; + // bool is excluded from the int-prim contract: cstage's + // `type_isint(TY_BOOL)` is false, so its identity check + // leaves src_w=0 on a bool source. Match that here so a + // `let y: i8 = b: i8;` (bool b) doesn't fire identity in + // wwstage and skip the MOVSBQ that cstage emits. Other + // call sites (slot sizing, etc.) still want + // primsize("bool")=1, so the exclusion stays local. The + // dedicated `is_bool` path in cgcast owns bool→bool's + // ANDQ $255 on both stages. + if (streq(nm, "bool")) { return; }; + let ps: i32 = primsize(nm); + if (ps > 0) { + *sz_out = ps; + *unsigned_out = typenameisunsigned(nm); + return; + }; + let al: *node = aliaslookup(c, nm); + if (al == nil) { return; }; + cur = al; + } + else { return; }; }; }; + }; +}; + +// exprprimresolved — best-effort static (primsize, signedness) for an +// expression. Used by cgcast (#33) to derive the source-side primitive +// width and signedness so the identity-width identity-sign clamp-skip +// predicate fires. Sets *sz_out = 0 when the type can't be derived +// (untyped literal, call result with no return-type lookup, etc.); +// caller treats sz=0 as "not identity", which conservatively keeps +// the clamp. Mirror of cstage's `n->lhs->type` lookup with the same +// TY_NAMED / TY_ENUM recursion through type_isint / type_isunsigned. +export fn exprprimresolved(c: *cgen, n: *node, + sz_out: *i32, unsigned_out: *bool) void = { + *sz_out = 0; + *unsigned_out = false; + if (n == nil) { return; }; + let k: nkind = n.kind; + if (k == nkind.N_INTLIT) { + // Typed-int literal: `7u32` has tsuffix = "u32". Mirrors + // cstage's `cexpr` which assigns `lookup_builtin(tsuffix)` + // as the node's type — without this, wwstage misses the + // suffix and emits a defensive clamp where cstage skips, + // breaking byte-id on rows like `let y: mymode = 7u32: + // mymode;` (mymode = enum u32). + let s: str = n.tsuffix; + if (s.len > 0) { + let ps: i32 = primsize(s); + if (ps > 0) { + *sz_out = ps; + *unsigned_out = typenameisunsigned(s); + }; + }; + return; + }; + if (k == nkind.N_IDENT) { + let lc: *local = localfindnode(c, n.str); + if (lc != nil) { + typenodeprimresolved(c, lc.tnode, + sz_out, unsigned_out); + }; + return; + }; + if (k == nkind.N_CAST) { + typenodeprimresolved(c, n.rhs, sz_out, unsigned_out); + return; + }; + if (k == nkind.N_UN) { + exprprimresolved(c, n.lhs, sz_out, unsigned_out); + return; + }; + if (k == nkind.N_DOT) { + typenodeprimresolved(c, dotfieldtnode(c, n), + sz_out, unsigned_out); + return; + }; +}; + // variantnamematch — tagged-union variant names are compared as if // they'd been alias-resolved. Pattern names can be module-qualified // (`strconv.invalid` from a `case let e: strconv.invalid =>`), @@ -9824,55 +9920,79 @@ fn cgcast(c: *cgen, n: *node) void = { // Mirrors cmd/w6c/cgen.c's N_CAST clamp. Unsigned narrow clears // the upper bits via MOVL/ANDQ; signed narrow sign-extends via // MOVSBQ/MOVSWQ/MOVSXD reg-reg so the sign bit propagates. + // + // Identity-width identity-sign cast is a no-op at the machine- + // int level: src and dst share both width and signedness, so the + // natural slot/load already carries the right canonical 64-bit + // shape. Skip the clamp in that case. Symmetric with cstage's + // principled gate (#33). Replaces the previous N_TENUM lacuna in + // this walker (the alias-step missed `N_TENUM`, so any cast to + // an enum dst landed on tn==nil and skipped the clamp by + // accident — task #25 mirrored that into cstage as a single-site + // gate, and #33 retires both). The walker now follows N_TENUM + // too so a narrow-to-enum cast (u32→enum-u8, i64→enum-i32) + // resolves to the underlying primitive and the clamp fires — + // fixing a silent miscompile in the process. if (srcfk == 0 && dstfk == 0) { - let tn: *node = n.rhs; - // Walk through alias chains (`type random = u64`) and the - // `!T` error-flag wrapper (`type invalid = !i32`) — the - // bang is a tagged-union marker, not a representational - // change, so it must not block the narrow-cast clamp. - for (tn != nil) { - if (tn.kind == nkind.N_TBANG) { tn = tn.lhs; } - else { if (tn.kind != nkind.N_TNAME) { tn = nil; } - else { - let nm: str = tn.str; - if (primsize(nm) > 0) { break; }; - let alias: *node = aliaslookup(c, nm); - if (alias == nil) { tn = nil; } - else { tn = alias; }; - }; }; + let sz: i32 = 0; + let is_unsigned: bool = false; + typenodeprimresolved(c, n.rhs, &sz, &is_unsigned); + let src_sz: i32 = 0; + let src_unsigned: bool = false; + exprprimresolved(c, n.lhs, &src_sz, &src_unsigned); + let identity: bool = false; + if (sz > 0) { if (src_sz == sz) { + if (src_unsigned == is_unsigned) { identity = true; }; + }; }; + // Detect bool dst by walking n.rhs to the leaf TNAME. bool + // keeps its dedicated ANDQ $255 contract regardless of + // upstream shape; it stays off the identity path. + let leaf_tn: *node = n.rhs; + for (leaf_tn != nil) { + let lk: nkind = leaf_tn.kind; + if (lk == nkind.N_TBANG) { leaf_tn = leaf_tn.lhs; } + else { if (lk == nkind.N_TENUM) { leaf_tn = leaf_tn.lhs; } + else { if (lk == nkind.N_TNAME) { + let lnm: str = leaf_tn.str; + if (primsize(lnm) > 0) { break; }; + let lal: *node = aliaslookup(c, lnm); + if (lal == nil) { leaf_tn = nil; } + else { leaf_tn = lal; }; + } + else { leaf_tn = nil; }; }; }; }; - if (tn != nil) { - let nm: str = tn.str; - let sz: i32 = primsize(nm); - let is_unsigned: bool = typenameisunsigned(nm); - let is_bool: bool = streq(nm, "bool"); - // Symmetric narrow on signed vs unsigned (task #5): - // unsigned (incl. rune) clears upper bits; signed - // sign-extends. bool is size 1 but neither — falls - // through to its dedicated ANDQ $255 below. - if (sz > 0) { if (sz < 8) { if (!is_bool) { - if (is_unsigned) { - if (sz == 4) { - emitline("\tMOVL\tAX, AX\n"); - } else { - let mask: i64 = 0xFFi64; - if (sz == 2) { mask = 0xFFFFi64; }; - emitline("\tANDQ\t$"); - emitint(mask); - emitline(", AX\n"); - }; + let is_bool: bool = false; + if (leaf_tn != nil) { + if (leaf_tn.kind == nkind.N_TNAME) { + is_bool = streq(leaf_tn.str, "bool"); + }; + }; + // Symmetric narrow on signed vs unsigned (task #5): + // unsigned (incl. rune) clears upper bits; signed + // sign-extends. bool is size 1 but neither — falls + // through to its dedicated ANDQ $255 below. + if (sz > 0) { if (sz < 8) { if (!is_bool) { if (!identity) { + if (is_unsigned) { + if (sz == 4) { + emitline("\tMOVL\tAX, AX\n"); } else { - if (sz == 1) { - emitline("\tMOVSBQ\tAX, AX\n"); - } else { if (sz == 2) { - emitline("\tMOVSWQ\tAX, AX\n"); - } else { if (sz == 4) { - emitline("\tMOVSXD\tAX, AX\n"); - }; }; }; + let mask: i64 = 0xFFi64; + if (sz == 2) { mask = 0xFFFFi64; }; + emitline("\tANDQ\t$"); + emitint(mask); + emitline(", AX\n"); }; - }; }; }; - if (is_bool) { emitline("\tANDQ\t$255, AX\n"); }; - }; + } else { + if (sz == 1) { + emitline("\tMOVSBQ\tAX, AX\n"); + } else { if (sz == 2) { + emitline("\tMOVSWQ\tAX, AX\n"); + } else { if (sz == 4) { + emitline("\tMOVSXD\tAX, AX\n"); + }; }; }; + }; + }; }; }; }; + if (is_bool) { emitline("\tANDQ\t$255, AX\n"); }; return; }; if (srcfk == 0 && dstfk == 2) { diff --git a/test/wcc/710_cast_enum_movl.c b/test/wcc/710_cast_enum_movl.c index a61e5020..2e423c52 100644 --- a/test/wcc/710_cast_enum_movl.c +++ b/test/wcc/710_cast_enum_movl.c @@ -1,60 +1,109 @@ /* * 710_cast_enum_movl — cstage and wwstage agree byte-for-byte on the - * N_CAST narrow-clamp when the dst is an enum (task #25). + * N_CAST narrow-clamp under the principled identity-width identity- + * sign predicate (task #33). Extended from the original #25 fixture + * which mirrored wwstage's N_TENUM lacuna as a single-site `tu->kind + * == TY_ENUM` gate in cstage. * - * Pre-fix: cstage's N_CAST handler in cmd/w6c/cgen.c walked - * `type_isint`/`type_isunsigned` which recurse through TY_ENUM into - * `t->sub`; for a u32→enum-u32 cast that landed on the size==4 unsigned - * branch and emitted a redundant `MOVL AX, AX`. Wwstage's cgcast walker - * (selfhost/cmd/wcc/cgenexpr.ww) only steps through N_TBANG / N_TNAME; - * `aliaslookup` on an enum returns the N_TENUM body, which breaks the - * loop and skips the clamp. + * Predicate (both stages): + * skip the narrow-clamp on an int→int cast iff + * src.width == dst.width && src.signed == dst.signed + * where (width, signedness) resolve through TY_NAMED / TY_ENUM + * alias chains in cstage and N_TBANG / N_TENUM / N_TNAME-alias + * chains in wwstage. Bool keeps its dedicated ANDQ $255 contract. * - * Surfaced by worker-stat during #10: when kstat.mode was first typed - * as raw `u32`, `out.mode = k.mode` parsed as a u32→enum-u32 cast via - * `fs.mode`, and the cstage→wwstage asm divergence broke 993_ww_ww + - * 995_self_rebuild on the first selfhost pass. Workaround in tree - * (lib/os/os.ww:498, kstat.mode: mode) sidesteps until this fix lands; - * reverting it is a sibling cleanup, out of scope here. + * History: #25 (b5632b1) shipped a single-site gate in cstage — + * `dst_is_enum → skip` — that made cstage byte-for-byte identical + * to wwstage on a u32→enum-u32 cast. It also inadvertently kept a + * silent miscompile alive: u32→enum-u8 and i64→enum-i32 also took + * the dst-is-enum exit, so the narrow-clamp didn't fire on a + * genuinely-width-narrowing cast and the upper bits of the source + * value leaked into any register-chained downstream use (the slot + * store happens to mask via MOVB/MOVL of the dst width, so program + * semantics looked right unless the result was consumed by a + * register-chained outer cast / arithmetic). * - * Fix shape: cstage's N_CAST clamp gate adds an explicit - * `tu->kind == TY_ENUM` skip, mirroring wwstage's lacuna. The predicate - * recursion through TY_ENUM in `type_isint`/`type_isunsigned` is - * preserved — other call sites depend on it; only this site gates - * explicitly. Skip is on dst kind only, so `enum-u32 → u32` still emits - * the clamp, matching wwstage's asymmetry. A principled - * identity-width identity-sign skip across both stages is filed as - * a separate followup. + * Surfaced by worker-stat during #10: when kstat.mode was first + * typed as raw `u32`, `out.mode = k.mode` parsed as a u32→enum-u32 + * cast via `fs.mode`, and the cstage→wwstage asm divergence broke + * 993_ww_ww + 995_self_rebuild on the first selfhost pass. The + * workaround that was in tree (lib/os/os.ww kstat.mode: mode) has + * already been retired by #25's single-site fix; #33 generalises + * the gate. * - * row | shape | gate - * -----------------+----------------------------------------+---------- - * u32_to_enum_u32 | `let y: m = x: m;` with m=enum u32. | exit=7 - * | Headline. Both stages emit ONE MOVL | + byte-id - * | (the slot load); no clamp. | - * enum_u32_to_u32 | reverse direction: `let z: u32 = y;`. | exit=7 - * | Both stages emit clamp MOVL AX, AX | + byte-id - * | (post-load) — pins the asymmetry. | - * u32_to_enum_u8 | dst is enum u8. Pre-fix cstage emits | exit=7 - * | ANDQ $0xFF; wwstage skips. Post-fix | + byte-id - * | both skip — matches wwstage. (Width | - * | narrowing through the enum-u8 is a | - * | known shared gap; principled followup. | - * | The u8-typed local's slot load uses | - * | MOVZBQ which masks anyway, so program | - * | semantics stays right at this width.) | - * i64_to_enum_i32 | signed-narrow: dst is enum i32. | exit=7 - * | Pre-fix cstage emits MOVSXD; post-fix | + byte-id - * | skips. The slot is read with MOVSXD | - * | downstream so sign-ext survives. | - * struct_field_rt | mirror of lib/os fillfilestat: a u32 | exit=7 - * | struct field copied into an enum-typed | + byte-id - * | field by chained N_DOT. Pins the field-| - * | store path through the cast. | + * row | shape | gate + * --------------------+--------------------------------------+---------- + * u32_to_enum_u32 | `let y: m = x: m;` with m=enum u32. | exit=7 + * | Identity (4B/unsigned). Both stages | + byte-id + * | skip — no clamp. | + * enum_u32_to_u32 | reverse: `let z: u32 = y: u32;`. | exit=7 + * | Also identity (4B/unsigned, walker | + byte-id + * | now resolves `mymode` through | + * | aliaslookup to u32). Both skip — | + * | flips from #25's clamp-emit. | + * u32_to_enum_u8 | dst is enum u8. Width narrows 4→1, | exit=7 + * | so identity is false. Both stages | + byte-id + * | now emit ANDQ $0xFF — flips from | + * | #25's skip. Fixes the silent leak | + * | (see u32_to_enum_u8_truncate below). | + * i64_to_enum_i32 | signed-narrow: dst is enum i32. | exit=7 + * | Width narrows 8→4 → identity false. | + byte-id + * | Both stages emit MOVSXD AX, AX — | + * | flips from #25's skip. Fixes the | + * | silent leak (see | + * | i64_to_enum_i32_truncate below). | + * struct_field_rt | mirror of lib/os fillfilestat: a u32 | exit=7 + * | struct field copied into an enum-u32 | + byte-id + * | field by chained N_DOT. Identity | + * | (4B/unsigned). Both skip. | + * u32_u32_identity | `let y: u32 = x: u32;` with src=u32. | exit=7 + * | Trivial identity. Both stages skip; | + byte-id + * | pre-#33 they emitted a redundant | + * | MOVL AX, AX. | + * i32_i32_identity | same shape, src/dst i32. Pre-#33 | exit=7 + * | both emitted MOVSXD AX, AX. Now | + byte-id + * | skip. | + * u8_u8_identity | u8 → u8. Pre-#33 ANDQ $0xFF. Now | exit=7 + * | skip. | + byte-id + * i8_i8_identity | i8 → i8. Pre-#33 MOVSBQ AX, AX. | exit=7 + * | Now skip. | + byte-id + * u16_u16_identity | u16 → u16. Pre-#33 ANDQ $0xFFFF. | exit=7 + * | Now skip. | + byte-id + * i16_i16_identity | i16 → i16. Pre-#33 MOVSWQ AX, AX. | exit=7 + * | Now skip. | + byte-id + * u32_to_i32_signchg | width equal, signedness differs. | exit=7 + * | Identity is FALSE → narrow-clamp | + byte-id + * | MUST fire. Both stages emit MOVSXD | + * | (dst is signed-narrow). Pin against | + * | future refactors that mis-broaden | + * | the skip. | + * u32_to_enum_u8_trnc | exit-code-validating silent- | exit=0 + * | miscompile fix. x=0xFFFFu32 cast to | + byte-id + * | enum-u8, then to u32, then divided | + * | by 0x100. Post-#33 the inner cast | + * | clamps to 0xFF, divide yields 0; | + * | pre-#33 the upper bits leaked | + * | (AX=0xFFFF), divide yielded 0xFF. | + * i64_to_enum_i32_trnc| same shape on i64 → enum-i32. | exit=0 + * | x=0x100000000i64 cast to enum-i32, | + byte-id + * | then to i64, divided by 0x100000000. | + * | Post-#33 MOVSXD takes low 32 bits | + * | (0), divide yields 0; pre-#33 the | + * | high 32 bits leaked, divide | + * | yielded 1. | * * Cstage exit-code rows confirm the binary still runs correctly - * post-fix; the asm-byte-id rows are the regression-pinning rows for - * the symmetric-emit contract. ww2!=ww3 byte-id (995_self_rebuild) - * covers a broader surface but doesn't isolate this corner. + * post-#33. The asm-byte-id rows pin the symmetric-emit contract. + * The `*_trnc` rows are the regression-pinning ones for the + * silent-miscompile fix that #25's dst-kind-only skip left in + * place. ww3!=ww4 byte-id (995_self_rebuild) covers a broader + * surface but doesn't isolate this corner. + * + * Note: removing the defensive MOVL exposes any upstream cgen path + * that leaves garbage in upper RAX when producing a sub-word value. + * If a future test goes red post-#33, the contract is violated + * somewhere — fix the upstream producer, do NOT reinstate the + * defensive clamp. */ #include #include @@ -89,11 +138,11 @@ static const struct row rows[] = { "};\n", 7 }, - /* 2. Reverse direction: enum-u32 → u32. Both stages still emit - * the clamp `MOVL AX, AX` here (wwstage's walker steps through - * the N_TNAME("u32") rhs and primsize=4 fires). The byte-id - * row pins that the fix didn't accidentally widen the skip to - * include this case. */ + /* 2. Reverse direction: enum-u32 → u32. Post-#33 both stages + * walk `mymode` through aliaslookup to u32, see (src u32, dst + * u32, both unsigned), and skip the narrow-clamp under the + * identity-width identity-sign predicate. Flips from #25's + * clamp-emit. Exit code unchanged at 7. */ { "enum_u32_to_u32", "type mymode = enum u32 { A = 1u32 };\n" "fn main() i32 = {\n" @@ -103,14 +152,13 @@ static const struct row rows[] = { "};\n", 7 }, - /* 3. Different enum width: u32 → enum-u8. Wwstage skips clamp - * because dst is an enum (lacuna); cstage now also skips - * (mirror). The local's u8 slot reads with MOVZBQ later, so - * the value 7 still reads back as 7. Pinning byte-id here - * documents that the skip is by dst-kind, not by dst-size — - * the principled identity-width fix would behave differently - * here, so this row is the canary that flips when the - * followup lands. */ + /* 3. Different enum width: u32 → enum-u8. Post-#33 both stages + * emit ANDQ $0xFF because identity is false (src 4B, dst 1B). + * Flips from #25's dst-is-enum skip. The slot write masks via + * MOVB so program semantics with `7` reads back as 7 either + * way; the silent-miscompile case (upper bits leaking into + * register-chained downstream use) is pinned by + * u32_to_enum_u8_trnc below. */ { "u32_to_enum_u8", "type small = enum u8 { A = 1u8 };\n" "fn main() i32 = {\n" @@ -120,10 +168,12 @@ static const struct row rows[] = { "};\n", 7 }, - /* 4. Signed-narrow path: i64 → enum-i32. Pre-fix cstage emitted - * `MOVSXD AX, AX`; wwstage skipped. Post-fix both skip. The - * enum-i32 local's slot read uses MOVSXD downstream so the - * sign-extension is recovered on use. */ + /* 4. Signed-narrow path: i64 → enum-i32. Post-#33 both stages + * emit MOVSXD AX, AX (identity false: src 8B, dst 4B). Flips + * from #25's skip. Slot is read with MOVSXD downstream so the + * sign-extension is recovered on use; silent leak through a + * register-chained outer cast is pinned by + * i64_to_enum_i32_trnc below. */ { "i64_to_enum_i32", "type sflag = enum i32 { A = 1i32 };\n" "fn main() i32 = {\n" @@ -135,9 +185,9 @@ static const struct row rows[] = { /* 5. Mirror of lib/os fillfilestat: struct field of one type * copied into an enum-typed field of another struct via - * chained N_DOT. The `out.mode = k.mode` shape is exactly - * what blew up worker-stat's first kstat.mode: u32 attempt - * pre-fix (993_ww_ww + 995_self_rebuild went red). */ + * chained N_DOT. Identity (4B/unsigned on both sides) → both + * stages skip the clamp. Pre-#25 this blew up 993_ww_ww + + * 995_self_rebuild on the first selfhost pass. */ { "struct_field_rt", "type mymode = enum u32 { A = 1u32 };\n" "type src = struct { mode: u32 };\n" @@ -149,6 +199,141 @@ static const struct row rows[] = { "\treturn (b.mode: u32): i32;\n" "};\n", 7 }, + + /* 6-11. Identity-width identity-sign rows. Pre-#33 the cast + * always emitted a clamp for sub-8B dst (MOVL/ANDQ/MOVSBQ/ + * MOVSWQ/MOVSXD depending on width and signedness); post-#33 + * all six skip because src and dst share the underlying + * primitive. Asm byte-id pins the contract. */ + { "u32_u32_identity", + "fn main() i32 = {\n" + "\tlet x: u32 = 7u32;\n" + "\tlet y: u32 = x: u32;\n" + "\treturn y: i32;\n" + "};\n", + 7 }, + { "i32_i32_identity", + "fn main() i32 = {\n" + "\tlet x: i32 = 7i32;\n" + "\tlet y: i32 = x: i32;\n" + "\treturn y;\n" + "};\n", + 7 }, + { "u8_u8_identity", + "fn main() i32 = {\n" + "\tlet x: u8 = 7u8;\n" + "\tlet y: u8 = x: u8;\n" + "\treturn (y: u32): i32;\n" + "};\n", + 7 }, + { "i8_i8_identity", + "fn main() i32 = {\n" + "\tlet x: i8 = 7i8;\n" + "\tlet y: i8 = x: i8;\n" + "\treturn (y: i32);\n" + "};\n", + 7 }, + { "u16_u16_identity", + "fn main() i32 = {\n" + "\tlet x: u16 = 7u16;\n" + "\tlet y: u16 = x: u16;\n" + "\treturn (y: u32): i32;\n" + "};\n", + 7 }, + { "i16_i16_identity", + "fn main() i32 = {\n" + "\tlet x: i16 = 7i16;\n" + "\tlet y: i16 = x: i16;\n" + "\treturn (y: i32);\n" + "};\n", + 7 }, + + /* 12. Width-equal sign-change: u32 → i32. Identity is FALSE + * (signedness differs) so the clamp MUST still emit (MOVSXD + * because dst is signed-narrow). Asm byte-id pins this + * against future refactors that mis-broaden the identity + * skip. Exit code 7 is just the value round-tripping. */ + { "u32_to_i32_signchg", + "fn main() i32 = {\n" + "\tlet x: u32 = 7u32;\n" + "\tlet y: i32 = x: i32;\n" + "\treturn y;\n" + "};\n", + 7 }, + + /* 13. Silent-miscompile fix, u32 → enum-u8. Pre-#33 the + * b5632b1 dst-is-enum skip left the upper bits of the u32 + * source in AX. With register-chained downstream use (no slot + * spill between the inner cast and the outer expression), the + * leak survives. Probe: start with x=0xFFFFu32, cast to + * enum-u8 (should clamp to 0xFF), cast to u32, divide by + * 0x100. Post-#33 the inner clamp leaves AX=0xFF and the + * divide yields 0; pre-#33 AX stayed 0xFFFF and the divide + * yielded 0xFF. Exit code distinguishes (0 vs 255). */ + { "u32_to_enum_u8_trnc", + "type small = enum u8 { A = 1u8 };\n" + "fn main() i32 = {\n" + "\tlet x: u32 = 0xFFFFu32;\n" + "\tlet r: u32 = ((x: small): u32) / 0x100u32;\n" + "\treturn r: i32;\n" + "};\n", + 0 }, + + /* 14. Silent-miscompile fix, i64 → enum-i32. Same shape on + * the signed-narrow path. x=0x100000000i64 (bit 32 set, low + * 32 bits zero). Post-#33 the MOVSXD takes the low 32 bits + * (0), AX=0, divide by 0x100000000 yields 0. Pre-#33 the + * clamp was skipped, AX stayed 0x100000000, divide yielded + * 1. Exit code distinguishes (0 vs 1). */ + { "i64_to_enum_i32_trnc", + "type sflag = enum i32 { A = 1i32 };\n" + "fn main() i32 = {\n" + "\tlet x: i64 = 0x100000000i64;\n" + "\tlet r: i64 = ((x: sflag): i64) / 0x100000000i64;\n" + "\treturn r: i32;\n" + "};\n", + 0 }, + + /* 15. Pseudo-field defensive-clamp pin. Source is `s.len`, a + * str header pseudo-field — neither stage's source-type + * resolver recognises it (cstage's `castsrcprim` gates the + * N_DOT branch on `bu->kind == TY_STRUCT`; wwstage's + * `exprprimresolved` routes through `dotfieldtnode` which + * returns nil for non-struct base). Both fall back to sz=0, + * identity is false, the narrow-clamp emits (MOVSXD here + * because dst is signed-narrow i32). Pinning byte-id on this + * row catches a future refactor that wires pseudo-field + * inference asymmetrically into one stage — the kind of drift + * that would silently break 995_self_rebuild without naming + * the corner. Exit code 7 = round-trip of the literal len. */ + { "pseudo_field_clamp", + "fn main() i32 = {\n" + "\tlet s: str = \"abcdefg\";\n" + "\tlet n: i32 = s.len: i32;\n" + "\treturn n;\n" + "};\n", + 7 }, + + /* 16. Bool source clamp pin. Source is a bool local, dst is i8. + * Width matches (1B) but bool is excluded from the int-prim + * contract on both stages (cstage's `type_isint(TY_BOOL)` is + * false; wwstage's `typenodeprimresolved` has an explicit + * `streq(nm, "bool") → return` early-out). So identity is + * never true on a bool source: the narrow-clamp emits + * (MOVSBQ AX, AX because dst is i8, signed-narrow). Without + * the bool early-out in wwstage, `primsize("bool")=1` and + * `typenameisunsigned("bool")=false` made wwstage see + * (sz=1, unsigned=false) and fire identity on bool→i8 while + * cstage emitted MOVSBQ — silent asm asymmetry that no other + * row exercises. Mirrors the `*_trnc` rows' pattern: the row + * pins the clamp emit, not just the exit code. */ + { "bool_to_i8_clamp", + "fn main() i32 = {\n" + "\tlet b: bool = true;\n" + "\tlet y: i8 = b: i8;\n" + "\treturn (y: i32);\n" + "};\n", + 1 }, }; static int