cstage+selfhost+test: enforce single-slot @retscr both stages (#14)

wwstage's $64 frame was 24B below required — the second struct-return's
@retscr write at -88(BP) landed below SP. Silent miscompile masked by
bootstrap-window luck. The fix retires the stomp by enforcing single-slot
@retscr at emit-time.

cstage was per-site-fresh (wasteful but safe, frame $96); aligned UP to
single-slot for ABI consistency with wwstage's @-prefix convention, not
for correctness. Both stages now produce $64 frame; second return reuses
the first's -64..-48(BP) slot.

Generalizes #38's c.tagscrsz SSoT pattern to c.retscroff (wwstage) and
cg_retscr (cstage). Returns are terminal — only one fires per call, so
the two slots' lifetimes never overlap; single-slot is structurally
correct. wwstage's emit-side dedup was incomplete post-#27 (cgblock
save/restore unwinds the @-prefix stub); the @retscr fast path in
localadd bypasses the c.locals walk.

Test 718: 4 rows × {cstage runtime, wwstage runtime, byte-id, stomp
sentinel}. Stomp sentinel scans .s for any -N(BP) where N>64 and fails
the row if found — catches below-SP writes that bootstrap byte-id would
miss in a lucky window. Row 2 (3-return) byte-id disabled per task #15
(pre-existing label-counter skew, unrelated to #14).

Polarity catalog this session:
- #9  wwstage OVER (tagged-return slot)
- #11 wwstage UNDER (struct-by-value param decompose)
- #14 wwstage UNDER (struct multi-return @retscr — silent stomp)
This commit is contained in:
2026-05-17 01:40:49 +09:00
parent 69a817f0f3
commit b401cced05
6 changed files with 565 additions and 7 deletions

View File

@@ -422,6 +422,15 @@ type cgen = struct {
// big enough for every later user. Single source of truth — pins
// rob's "scan + emit lockstep" invariant. Reset per cgfn.
tagscrsz: i32,
// Live @retscr offset (#14). c.locals-based `@`-prefix dedup in
// localadd is unwound by cgblock save/restore (post-#27), so a
// second `return` in a sibling/outer block reallocates a fresh
// slot — emit grew the frame past what scanlocals reserved, and
// the stomp landed below SP. retscroff is the persistent SSoT:
// 0 means "not yet allocated"; first emit-site sets it, every
// later emit reuses. Mirrors c.tagscrsz pattern (#38) but tracks
// offset, not size (per-fn return type is fixed, so size is too).
retscroff: i32,
};
// Top-level mutable `let` registry. Mirrors cmd/w6c/cgen.c LetVar.
@@ -444,6 +453,7 @@ fn cgeninit(c: *cgen, a: *arena) void = {
c.labelseq = 0;
c.varargseq = 0;
c.tagscrsz = 0;
c.retscroff = 0;
// Note: strlit_seq, strlits, ffis are *not* reset here; they
// persist across cgfn calls within one file. cgfile resets them
// at the start of each compilation unit.
@@ -510,8 +520,23 @@ fn localadd(c: *cgen, name: str, sz: i32, tnode: *node) i32 = {
// reservation and emit-time allocation stay in sync. The
// `@`-prefix carve-out preserves that contract; user names
// can never start with `@` (lexer-rejected).
//
// @retscr (#14) routes through c.retscroff instead of c.locals.
// The c.locals-based dedup is unwound by cgblock save/restore
// (post-#27): a return inside an `if` block adds @retscr to
// c.locals; on block exit, c.locals reverts and a sibling/outer
// return reallocates a fresh slot. Scan had reserved one slot;
// emit grew the frame past the reservation and the second
// site's writes landed below SP. c.retscroff is per-fn state
// that survives cgblock save/restore and pins single-slot.
if (name.len > 0) {
if (name[0] == 64u8) { // '@'
if (streq(name, "@retscr")) {
if (c.retscroff != 0) { return c.retscroff; };
let off: i32 = localalloc(c, name, sz, tnode);
c.retscroff = off;
return off;
};
let cur: *local = c.locals;
for (cur != nil) {
let cn: str = cur.name;