w6c+w6c_ww: tagged sret for slot>32B returns (fix #38)

A tagged-union RETURN rides a fixed AX(tag)+DX/CX/R8 cursor (TUPLE_GPCAP
eightbytes = 32B slot); wider slots were silently truncated at the
return crossing — payload word 4+ built in the callee frame and died
there, byte-identical on both stages (gate-blind). Blocks regex fold-2a
((regex | error | nomem) = 64B slot).

Classifier: cg_sret_retsize / sretretsize gain a TY_TAGGED arm
(<= TUPLE_GPCAP*8 stays register-ABI — the (str|nomem)/(s3|bool) 32B
boundary class is pinned unchanged byte-for-byte vs master). Callee:
cgreturn writes the slot through *(@sretarg) via the existing widener
non-BP base (bare return stores the void tag); exact-type 'return f();'
rides the #9 sret-forward. Receive: let/assign/discard reuse the
generic #23/#10 sret protocol; the match scrutinee passes its spill
slot as the sret dest (tagged-specific, no tuple precedent).

This could NOT land as a gate-first interim loud-stop (the planned
#38a): lib/errors/errors.ww errno() already returns a 40B
(errors.error) slot in-tree — the cgenstmt.ww-documented #222 latent —
so a bare gate breaks the build. errno graduates to sret here instead;
errnotest pins it at runtime (its cstage run; the wwstage run was
already failing at master via an unrelated pre-existing indirect-call
arg-classification divergence, reported separately) and test/926's
errno-shaped row reads the previously-dropped tail word on both stages.

The unwired cursor consumers of an sret-class call result loud-stop
(rule 7) rather than read a cursor the callee no longer fills:
widening forward/receive ((A|B)->(A|B|C) mem-to-mem tag-remap, filed
#40), ?/!/is/as operands, argument position, and the >48B tagged-arg
class both stages previously mishandled silently. One-class-one-commit
per the #133 carve-out: post-flip those consumers would read AX (now
the dest pointer) as the tag — a gates-trailing commit would leave a
silently-wrong bisect point, so the flip and its gates are not
separable.

test/926: 15 rows — 56B regex-shaped round-trips (literal/local/
assign/match-scrutinee/forward/str-variant/multi-call), 40B repro +
bare-return-void, the errno-shaped tail-read graduation row, 32B
boundary rows pinned register-ABI by asm sentinel, and 3 loud-stop
rows pinned as build failures on both stages.
This commit is contained in:
2026-06-04 03:43:44 +09:00
parent 5f15eb3d09
commit 4f3967835e
8 changed files with 1660 additions and 22 deletions

View File

@@ -322,6 +322,15 @@ cg_sret_retsize(Type *rt)
if (rt == NULL) return 0;
if (rt->kind == TY_STRUCT)
return (int)rt->size <= 24 ? 0 : (int)rt->size;
/* #38: a tagged union rides AX(tag)+DX/CX/R8 = TUPLE_GPCAP
* eightbytes; a wider slot was silently truncated (payload word
* 4+ died in the callee frame). The ≤cap boundary is load-bearing:
* (str|nomem)-shaped 32B slots MUST stay register-ABI or every
* such consumer in the tree flips. Nullable folds to one word. */
if (rt->kind == TY_TAGGED) {
if (rt->nullable) return 0;
return (int)rt->size <= TUPLE_GPCAP * 8 ? 0 : (int)rt->size;
}
/* #267: arrays ride the struct-return ABI — same ≤24 reg / >24 sret
* split. Pure-int element arrays only; no float-array-return
* consumer exists, so struct_float_class stays struct-only. */
@@ -2163,6 +2172,16 @@ cg_widen_tagged_store(Cg *c, Local **locals_p, Type *dst, Node *src,
amem(D_BP, write_off + 8 + k));
}
} else {
/* #38b: an sret-classified call result is in
* memory (AX = dest pointer), not the cursor —
* the spill below would store the pointer as
* the payload. Mem-to-mem widen is #40. */
if (src->kind == N_CALL
&& cg_sret_retsize(st) > 0)
fatal("#40: sret-class call result "
"cannot be cursor-widened into a "
"tagged slot (mem-to-mem widen "
"unwired)");
cgexpr(c, src, *locals_p);
ins2(c, A_MOVQ, areg(D_AX),
amem(D_BP, write_off + 8));
@@ -2196,6 +2215,12 @@ cg_widen_tagged_store(Cg *c, Local **locals_p, Type *dst, Node *src,
* variant-widen) so the unconditional store here is
* safe even when the source variant has fewer payload
* words than the dst slot. */
/* #38b: an sret-classified call result is in memory
* (AX = dest pointer), not the cursor. #40. */
if (src->kind == N_CALL && cg_sret_retsize(st) > 0)
fatal("#40: sret-class call result cannot be "
"cursor-widened into a tagged slot "
"(mem-to-mem widen unwired)");
cgexpr(c, src, *locals_p);
ins2(c, A_MOVQ, areg(D_AX),
amem(D_BP, write_off + 0));
@@ -2345,7 +2370,7 @@ cg_widen_tagged_store(Cg *c, Local **locals_p, Type *dst, Node *src,
? ftype->under : ftype;
/* str IS []u8 and a slice is the same 3-word
* {ptr,len,cap} header from cgexpr's AX/BX/CX
* (#1/Phase 3). The slice arm rides #38's
* (#1/Phase 3). The slice arm rides #38b's
* regex-shaped consumer (slice fields inside a
* union-payload struct literal); the prior
* str-only gate dropped .len/.cap via the
@@ -5626,17 +5651,36 @@ cgexpr(Cg *c, Node *n, Local *locals)
/* Plain `r = expr;` where r is a tagged-union local.
* Delegates to cg_widen_tagged_store: covers nullable fold,
* tagged→tagged (with tag remap), struct payload (ident or
* literal), str payload, and scalar payload. */
* literal), str payload, and scalar payload.
*
* #38b: an sret-classified tagged CALL result is in memory,
* not the cursor — an exact-type reassign falls through to
* the generic sret receive below; a widening receive needs
* mem-to-mem tag-remap (#40, unwired). */
if (n->lhs && n->lhs->kind == N_IDENT && n->op == TK_ASSIGN
&& n->lhs->type) {
Type *lt = n->lhs->type;
Type *lu = (lt && lt->kind == TY_NAMED) ? lt->under : lt;
if (lu && lu->kind == TY_TAGGED) {
int off = localfind(locals, n->lhs->str);
if (off == 0) break;
cg_widen_tagged_store(c, &locals, lu, n->rhs,
D_BP, off, (int)lu->size);
break;
int rhs_sret_call = n->rhs
&& n->rhs->kind == N_CALL
&& cg_sret_retsize(n->rhs->type) > 0;
if (!rhs_sret_call) {
int off = localfind(locals,
n->lhs->str);
if (off == 0) break;
cg_widen_tagged_store(c, &locals, lu,
n->rhs, D_BP, off, (int)lu->size);
break;
}
Type *ru = type_chase_named(n->rhs->type);
if (!(ru == lu || type_eq(n->rhs->type, lt)))
fatal("#40: sret-class call result "
"cannot be widened into a tagged "
"slot (mem-to-mem widen unwired)");
if (localfind(locals, n->lhs->str) == 0)
fatal("#38b: sret receive into a "
"tagged GLOBAL lvalue unwired");
}
}
/* Deref-target assignment `*p = v;`. The size of the store is
@@ -7020,6 +7064,20 @@ cgexpr(Cg *c, Node *n, Local *locals)
args[i], widen_sz[i]);
continue;
}
/* #38b residual (rule 7): a tagged arg slot past the
* 6-reg arg capacity has no push shape —
* tagged_arg_size returns 0 ("too large") and the
* scalar default silently pushed ONE word. Loud-stop;
* symmetric ww gate in pushargsrev. */
{
Type *au = type_chase_named(args[i]->type);
if (au && au->kind == TY_TAGGED
&& !au->nullable
&& tagged_arg_size(args[i]->type) == 0)
fatal("#38b: tagged arg exceeds the "
"register arg capacity (>48B "
"slot) — unwired");
}
cgexpr(c, args[i], locals);
Type *tuparg_push = node_tuplearg(args[i]);
if (node_isfloat(args[i])) {
@@ -7052,6 +7110,15 @@ cgexpr(Cg *c, Node *n, Local *locals)
* values into arg-reg[1..]. Nullable (sz=8):
* AX holds the pointer, no value-word
* registers — push just AX. */
/* #38b residual (rule 7): an sret-class call
* result is in memory, not the cursor — the
* @aggargscr-style receive-then-push is the
* #40-family follow-up. */
if (args[i]->kind == N_CALL
&& cg_sret_retsize(args[i]->type) > 0)
fatal("#38b: >32B tagged call result "
"as a call argument unwired "
"(#40-family follow-up)");
int sz = tagged_arg_size(args[i]->type);
if (sz > 24)
ins1(c, A_PUSHQ, areg(D_R8));
@@ -7517,6 +7584,16 @@ cgexpr(Cg *c, Node *n, Local *locals)
* spill only that. */
sl_off = localoff(c, &locals, "@match_spill", slot_size,
cg_frame);
if (s->kind == N_CALL && cg_sret_retsize(st) > 0) {
/* #38b: sret-classified tagged call — pass the
* scrut slot itself as the sret dest and skip
* the cursor spill; downstream tag dispatch /
* case-let binds already read the slot from
* memory. */
cg_sret_dest_off = sl_off;
cgexpr(c, s, locals);
cg_sret_dest_off = 0;
} else {
cgexpr(c, s, locals);
ins2(c, A_MOVQ, areg(D_AX), amem(D_BP, sl_off + 0));
if (!is_nullable) {
@@ -7529,6 +7606,7 @@ cgexpr(Cg *c, Node *n, Local *locals)
ins2(c, A_MOVQ, areg(D_R8),
amem(D_BP, sl_off + 24));
}
}
}
char *end = mklabel(c, "match_end");
/* Push the end label as the yield target for arm bodies. */
@@ -7648,6 +7726,20 @@ cgexpr(Cg *c, Node *n, Local *locals)
* (any non-null), void variant is the error (null). The
* enclosing fn's null encoding is the same — RET with AX=0
* if propagating; otherwise leave AX as-is on success. */
/* #38b residuals (rule 7): the cursor read below cannot see
* an sret-classified call result (AX = dest pointer), and the
* propagate-RET below cannot speak an sret-classified
* enclosing return (the caller reads memory, not the
* cursor). Both are unwired follow-ups of #40's family. */
if (n->lhs && n->lhs->kind == N_CALL
&& cg_sret_retsize(n->lhs->type) > 0)
fatal("#38b: `?` on an sret-class call result "
"unwired (mem-based unwrap is a #40-family "
"follow-up)");
if (cg_sret_retsize(cg_ret_type) > 0)
fatal("#38b: `?` propagation into a >32B tagged "
"return unwired (sret error-propagate is a "
"#40-family follow-up)");
cgexpr(c, n->lhs, locals);
Type *u = n->lhs ? n->lhs->type : NULL;
if (u && u->kind == TY_NAMED) u = u->under;
@@ -7728,6 +7820,12 @@ cgexpr(Cg *c, Node *n, Local *locals)
/* On error variant: exit(1) directly via the syscall.
* Nullable: null = error; non-null = success (AX is the
* pointer, ready to use). */
/* #38b residual (rule 7): see the N_TRYPROP twin. */
if (n->lhs && n->lhs->kind == N_CALL
&& cg_sret_retsize(n->lhs->type) > 0)
fatal("#38b: `!` on an sret-class call result "
"unwired (mem-based unwrap is a #40-family "
"follow-up)");
cgexpr(c, n->lhs, locals);
Type *u = n->lhs ? n->lhs->type : NULL;
if (u && u->kind == TY_NAMED) u = u->under;
@@ -7779,6 +7877,13 @@ cgexpr(Cg *c, Node *n, Local *locals)
/* `e is T` — Compare scrutinee tag against T's variant index.
* Result is bool (0/1) in AX. Nullable: discriminator is
* pointer-vs-null, not a tag. */
/* #38b residual (rule 7): an sret-class call result leaves
* AX = dest pointer, not the tag — mem-based test is a
* #40-family follow-up. */
if (n->lhs && n->lhs->kind == N_CALL
&& cg_sret_retsize(n->lhs->type) > 0)
fatal("#38b: `is` on an sret-class call result "
"unwired (#40-family follow-up)");
cgexpr(c, n->lhs, locals);
Type *u = n->lhs ? n->lhs->type : NULL;
if (u && u->kind == TY_NAMED) u = u->under;
@@ -7835,6 +7940,11 @@ cgexpr(Cg *c, Node *n, Local *locals)
}
}
int slot_size = (u && u->kind == TY_TAGGED) ? (int)u->size : 16;
/* #38b residual (rule 7): the @asrt_spill below reads the
* cursor, which an sret-class call result never fills. */
if (s && s->kind == N_CALL && cg_sret_retsize(st) > 0)
fatal("#38b: `as` on an sret-class call result "
"unwired (#40-family follow-up)");
int sl_off = 0;
if (s && s->kind == N_IDENT && s->str) {
sl_off = localfind(locals, s->str);
@@ -9327,14 +9437,43 @@ cgstmt(Cg *c, Node *n, Local **locals, int *frame)
}
break;
}
/* #38b residual (rule 7): `let w: T = f()?;` / `f()!` where f
* returns an sret-classified tagged union — the unwrap would
* need a mem-based read of the sret slot. The N_LET arms
* below have no TRYUNW/TRYPROP shape for a >8B lt, so the
* rhs was SILENTLY dropped (no CALL emitted; wwstage's cglet
* default does cgexpr and hits the cgtryunw/cgtryprop gates —
* this keeps acceptance symmetric, rule 10). */
if (n->rhs
&& (n->rhs->kind == N_TRYUNW || n->rhs->kind == N_TRYPROP)
&& n->rhs->lhs && n->rhs->lhs->kind == N_CALL
&& cg_sret_retsize(n->rhs->lhs->type) > 0)
fatal("#38b: `?`/`!` on an sret-class call result "
"unwired (mem-based unwrap is a #40-family "
"follow-up)");
/* Tagged-union initialiser. Delegates to cg_widen_tagged_store,
* which handles nullable fold, tagged→tagged (with tag remap
* when variant indices differ), struct payload (ident or
* literal — field-by-field at slot+8+field_off), str payload,
* and scalar payload (with zero-pad to the slot size). */
* and scalar payload (with zero-pad to the slot size).
*
* #38b: an sret-classified tagged CALL result is in memory,
* not the cursor — an exact-type receive falls through to the
* generic sret receive below (the let's slot IS the dest); a
* widening receive needs mem-to-mem tag-remap (#40, unwired). */
if (n->rhs && lu && lu->kind == TY_TAGGED) {
cg_widen_tagged_store(c, locals, lu, n->rhs, D_BP, off, sz);
break;
int rhs_sret_call = n->rhs->kind == N_CALL
&& cg_sret_retsize(n->rhs->type) > 0;
if (!rhs_sret_call) {
cg_widen_tagged_store(c, locals, lu, n->rhs,
D_BP, off, sz);
break;
}
Type *ru = type_chase_named(n->rhs->type);
if (!(ru == lu || type_eq(n->rhs->type, lt)))
fatal("#40: sret-class call result cannot be "
"widened into a tagged slot (mem-to-mem "
"widen unwired)");
}
/* Every slice initialiser routes here — fn-return, slice
* ident, slice param, and sub-slice `buf[lo:hi]`. cgexpr
@@ -9749,6 +9888,27 @@ cgstmt(Cg *c, Node *n, Local **locals, int *frame)
Type *rt = cg_ret_type;
if (rt->kind == TY_NAMED) rt = rt->under;
if (rt && rt->kind == TY_TAGGED) {
/* #38b: an sret-classified tagged return (slot
* > the AX/DX/CX/R8 cursor) writes the void-
* variant tag through *(@sretarg) and returns
* the dest pointer — the cursor can't carry the
* slot and the caller reads memory. */
if (cg_sret_retsize(rt) > 0) {
int tag = cg_tag_for_variant(rt, ty_void);
if (tag < 0) tag = 0;
ins2(c, A_MOVQ,
amem(D_BP, cg_sret_arg_off),
areg(D_BX));
ins2(c, A_MOVQ, aimm(tag),
amem(D_BX, 0));
ins2(c, A_MOVQ,
amem(D_BP, cg_sret_arg_off),
areg(D_AX));
ins2(c, A_MOVQ, areg(D_BP), areg(D_SP));
ins1(c, A_POPQ, areg(D_BP));
ins0(c, A_RET);
break;
}
if (rt->nullable) {
/* bare `return;` is the void/null
* variant: emit AX = 0. */
@@ -9796,6 +9956,58 @@ cgstmt(Cg *c, Node *n, Local **locals, int *frame)
* arm below zeroed the whole value (never packed the
* operands). */
int istuple = vu && vu->kind == TY_TUPLE;
/* #38b: sret-classified tagged return (slot >
* the AX/DX/CX/R8 cursor). Three shapes:
* - exact-type N_CALL forward: inner sret's
* straight into outer's caller dest (#9
* shape, cg_sret_forward).
* - widening from an sret-class tagged source
* ((A|B)→(A|B|C) mem-to-mem tag-remap):
* unwired, loud-stop — #40.
* - everything else: cg_widen_tagged_store
* through *(@sretarg) (the widener already
* speaks non-BP bases, the #34 precedent),
* then return the dest pointer. */
if (cg_sret_retsize(rt) > 0) {
int sz = (int)rt->size;
if (passthrough) {
/* exact type but a cursor source
* (N_INDEX/N_DOT) can't carry
* >32B — loud-stop (rule 7,
* #38b residual). */
if (n->lhs->kind != N_CALL)
fatal("#38b: >32B tagged "
"return from a cursor "
"source (kind %d) "
"unsupported",
n->lhs->kind);
cg_sret_forward = 1;
cgexpr(c, n->lhs, *locals);
ins2(c, A_MOVQ,
amem(D_BP, cg_sret_arg_off),
areg(D_AX));
} else if (istagged
&& n->lhs->kind != N_IDENT
&& (int)vu->size > TUPLE_GPCAP * 8) {
fatal("#40: widening tagged "
"return-forward of a >32B "
"source needs mem-to-mem "
"tag-remap (unwired)");
} else {
ins2(c, A_MOVQ,
amem(D_BP, cg_sret_arg_off),
areg(D_BX));
cg_widen_tagged_store(c, locals,
rt, n->lhs, D_BX, 0, sz);
ins2(c, A_MOVQ,
amem(D_BP, cg_sret_arg_off),
areg(D_AX));
}
ins2(c, A_MOVQ, areg(D_BP), areg(D_SP));
ins1(c, A_POPQ, areg(D_BP));
ins0(c, A_RET);
break;
}
if (rt->nullable) {
cgexpr(c, n->lhs, *locals);
} else if (passthrough) {