/* * 710_cast_enum_movl — cstage and wwstage agree byte-for-byte on the * 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. * * 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. * * 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). * * 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 * | 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-#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 #include #include #include #include static int runwait(const char *cmd) { int rc = system(cmd); if (rc == -1) return -1; if (WIFEXITED(rc)) return WEXITSTATUS(rc); return -1; } struct row { const char *label; const char *src; int want; }; static const struct row rows[] = { /* 1. Headline #25 case: u32 → enum-u32. Pre-fix cstage emitted * `MOVL AX, AX` after the slot load; wwstage skipped. The cast * is a no-op at runtime so program semantics is unchanged * either way — exit code stays 7 regardless. The asm-byte-id * row is what catches the regression. */ { "u32_to_enum_u32", "type mymode = enum u32 { A = 1u32, B = 2u32 };\n" "fn main() i32 = {\n" "\tlet x: u32 = 7u32;\n" "\tlet y: mymode = x: mymode;\n" "\treturn (y: u32): i32;\n" "};\n", 7 }, /* 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" "\tlet y: mymode = 7u32: mymode;\n" "\tlet z: u32 = y: u32;\n" "\treturn z: i32;\n" "};\n", 7 }, /* 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" "\tlet x: u32 = 7u32;\n" "\tlet y: small = x: small;\n" "\treturn (y: u32): i32;\n" "};\n", 7 }, /* 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" "\tlet x: i64 = 7i64;\n" "\tlet y: sflag = x: sflag;\n" "\treturn (y: i32);\n" "};\n", 7 }, /* 5. Mirror of lib/os fillfilestat: struct field of one type * copied into an enum-typed field of another struct via * 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" "type dst = struct { mode: mymode };\n" "fn main() i32 = {\n" "\tlet a: src = src { mode = 7u32 };\n" "\tlet b: dst;\n" "\tb.mode = a.mode: mymode;\n" "\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 run_driver(const char *driver, const struct row *r, int i) { char src[64], tmpdir[64], cmd[1024]; snprintf(src, sizeof src, "/tmp/cem_%d_%d.ww", getpid(), i); snprintf(tmpdir, sizeof tmpdir, "/tmp/cem_%d_d_%d", getpid(), i); FILE *f = fopen(src, "wb"); if (!f) return -1; fputs(r->src, f); fclose(f); mkdir(tmpdir, 0755); snprintf(cmd, sizeof cmd, "cd %s && %s build %s 2>/dev/null", tmpdir, driver, src); if (runwait(cmd) != 0) { fprintf(stderr, "row[%s]: build via %s failed\n", r->label, driver); unlink(src); rmdir(tmpdir); return -1; } const char *base = strrchr(src, '/'); base = base ? base + 1 : src; char outbin[128]; snprintf(outbin, sizeof outbin, "%s/%s", tmpdir, base); char *dot = strrchr(outbin, '.'); if (dot && strcmp(dot, ".ww") == 0) *dot = '\0'; int got = runwait(outbin); unlink(src); unlink(outbin); rmdir(tmpdir); return got; } /* asm_byte_identical — generate .s via cstage's w6c and wwstage's * w6c_ww and diff. This is the regression-pinning row for #25; an * exit-code-only comparison wouldn't catch a redundant MOVL drift * because the program semantics is unchanged. */ static int asm_byte_identical(const char *bin, const struct row *r, int i) { char src[64], cs[64], ws[64], cmd[1024]; snprintf(src, sizeof src, "/tmp/cem_asm_%d_%d.ww", getpid(), i); snprintf(cs, sizeof cs, "/tmp/cem_asm_%d_%d_c.s", getpid(), i); snprintf(ws, sizeof ws, "/tmp/cem_asm_%d_%d_w.s", getpid(), i); FILE *f = fopen(src, "wb"); if (!f) return -1; fputs(r->src, f); fclose(f); snprintf(cmd, sizeof cmd, "%s/w6c -o %s %s 2>/dev/null", bin, cs, src); if (runwait(cmd) != 0) { fprintf(stderr, "row[%s]: w6c errored\n", r->label); unlink(src); return -1; } snprintf(cmd, sizeof cmd, "%s/w6c_ww -o %s %s 2>/dev/null", bin, ws, src); if (runwait(cmd) != 0) { fprintf(stderr, "row[%s]: w6c_ww errored\n", r->label); unlink(src); unlink(cs); return -1; } FILE *fc = fopen(cs, "rb"); FILE *fw = fopen(ws, "rb"); int rc = 0; if (!fc || !fw) { rc = -1; } else { for (;;) { int a = fgetc(fc); int b = fgetc(fw); if (a != b) { rc = -1; break; } if (a == EOF) break; } } if (fc) fclose(fc); if (fw) fclose(fw); if (rc != 0) fprintf(stderr, "row[%s]: cstage vs wwstage asm differs\n", r->label); unlink(src); unlink(cs); unlink(ws); return rc; } int main(void) { const char *bin = getenv("BIN"); if (!bin) bin = "out/bin"; char absbin[1024]; if (bin[0] != '/') { char cwd[1024]; if (getcwd(cwd, sizeof cwd) == NULL) return 1; snprintf(absbin, sizeof absbin, "%s/%s", cwd, bin); bin = absbin; } char cdrv[1024]; snprintf(cdrv, sizeof cdrv, "%s/ww", bin); char wdrv[1024]; snprintf(wdrv, sizeof wdrv, "%s/ww_ww", bin); struct { const char *name; const char *path; int gated_on_existence; } drivers[] = { { "cstage", cdrv, 0 }, { "wwstage", wdrv, 1 }, { NULL, NULL, 0 }, }; int n = (int)(sizeof rows / sizeof rows[0]); int total = 0, fail = 0; for (int d = 0; drivers[d].name; d++) { if (drivers[d].gated_on_existence && access(drivers[d].path, X_OK) != 0) { fprintf(stderr, "cast_enum_movl: skip %s (no %s)\n", drivers[d].name, drivers[d].path); continue; } for (int i = 0; i < n; i++) { int got = run_driver(drivers[d].path, &rows[i], i); total++; if (got != rows[i].want) { fprintf(stderr, "cast_enum_movl[%s][%s]: exit=%d want=%d\n", drivers[d].name, rows[i].label, got, rows[i].want); fail++; } } } /* Asm byte-identity diff, only when wwstage is built. This is * the row that pins the #25 fix. */ if (access(wdrv, X_OK) == 0) { for (int i = 0; i < n; i++) { total++; if (asm_byte_identical(bin, &rows[i], i) != 0) fail++; } } if (fail) { fprintf(stderr, "cast_enum_movl: %d/%d fixtures failed\n", fail, total); return 1; } printf("cast_enum_movl: %d/%d ok\n", total, total); return 0; }