Add SAR/SARQ to both assemblers' opcode tables (cstage cmd/w6a +
wwstage selfhost/cmd/w6a) — REX.W + D3 /7, parallel to SHR's D3 /5.
Encoding is the unary-on-CL form (SAR r/m64, CL), the only variant
the cgen emits today. cstage cgen + wwstage cgen sweep all 12 SHRQ
emission sites (6 per stage) so signed RSHIFT and signed RSHIFTEQ
route through SARQ (arithmetic, sign-extends MSB) instead of SHRQ
(logical, zero-fill). Pre-fix `let i: i32 = -200; i >>= 2;`
produced 0x3FFFFFCE (1073741774) instead of -50; cs==ww held because
BOTH stages emitted SHRQ, so the 990-997 byte-id gates were
gate-blind to this silent miscompile.
Sites covered (per stage 6, same shape in both):
- plain TK_RSHIFT (cgbin / N_BIN ordered binop) — derives unsignd
from operand types via type_isunsigned / nodeisunsigned, picks
SHRQ vs SARQ at emit
- chained-ptr-field compound RSHIFTEQ (cgen.c:3281-3317 area)
- N_INDEX-lhs compound RSHIFTEQ (#133-expanded N_INDEX site)
- deref-target compound RSHIFTEQ
- top-level let compound RSHIFTEQ
- IDENT-local compound RSHIFTEQ
All sites reuse the in-scope unsignd variable from the surrounding
SLASHEQ block (or derive one locally when not available). LSHIFTEQ
unchanged — SHL == SAL at the encoder, no signedness dispatch needed.
912_sar_shr_run: 5 rows. i32_neg_rshifteq (lead's repro, was wrong
1073741774 → now -50), i64_neg_rshifteq (wider type), i32_pos_
rshifteq (positive control, SARQ ≡ SHRQ on positives, no regression),
u32_rshifteq (unsigned control, still SHRQ), i32_neg_rshift_binop
(plain >> not compound, cgbin TK_RSHIFT site). Exit codes use small
absolute values with u8 wrap (-50 = 206) per Unix 8-bit exit.
Bootstrap-NEUTRAL — `grep -rE '>>=|>>\b'` in lib/+selfhost/ (excl.
combined.ww) returned zero callers of signed RSHIFT today; the only
asm shifts are on previously-broken paths. 990-997 + combined_ww_
fresh stay green. Closes the silent-misbehavior class on signed
right-shift across all 12 cgen emission paths in one fold per
rule-11. Foundation for Eisel-Lemire (strconv fold-4) big-int signed
shifts.
`for (init; cond; post) { ... continue; ... }` and `for (let i .. xs)
{ ... continue; ... }` now emit a `post` (3-clause) or `rpost` (range)
label between the body and the JMP back to the cond-test. `continue`
jumps to that label, runs the post-step, then re-tests the loop
condition — mirrors C/Go/Hare semantics. Pre-fix both stages emitted
`JMP loop_top` for continue, SKIPPING the post-step → the value that
triggered continue never advanced → silent infinite loop on the first
matching iteration. Found by impl-strconv-fold2 during the fold-3
decimal.ha port: `leftshift_newdigits`'s `for (... i+=1) { ... else
if (d.digits[i]==p5[i]) continue; ... }` would infinite-loop at the
first equal digit.
BOTH stages were identically buggy → 990-997 cs==ww byte-id held →
gate-blind. Bootstrap audit (`grep -rE 'for \(let .*\.\.' lib/
selfhost/`) confirmed zero existing callers with continue in either
the 3-clause or range form; bootstrap-NEUTRAL.
Sites: cmd/w6c/cgen.c N_FOR + N_FORRANGE; selfhost/cmd/wcc/
cgenstmt.ww cgfor + cgforrange. 1-clause `for (cond)` byte-id
preserved (cont_target stays = loop_top when n.rhs == nil). Rule-11
carve-out: 3-clause and range share the lowered structure; fixing
one without the other would leave the same silent miscompile in
N_FORRANGE — one-class closure on the continue-skips-post bug, same
precedent as #133-expanded.
911_continue_run: 4 rows. for3_skip_one (lead's repro, was infinite
loop, now 4), for3_skip_two (nested continues, 30), range_skip
(Hare-range continue, was infinite loop, now 120), for1_continue_
byteid (1-clause regression assertion — bootstrap shape unchanged).
Pre-existing parser-side divergences (cstage silently drops post in
the never-used 2-clause `for (cond; post)`; wwstage doesn't support
infinite `for {}`) deferred to #139 — not in decimal.ha, no shared
class with the cgen continue-skips-post.
Strategy (a) use-site fix: new helper cg_dotbase_addr (cstage) /
dotbaseaddr (wwstage) detects `base.kind == N_DOT` whose field type
is TY_ARRAY and emits the field's address inline — LEAQ inner_off+
field_off(BP) for a value-struct inner, MOVQ inner_off(BP),reg +
ADDQ field_off,reg for a *struct inner. The TY_ARRAY-only gate (after
TY_NAMED peel) keeps the helper INERT on TY_PTR/TY_SLICE/TY_STR/
TY_TAGGED field kinds where the existing cgexpr(base) path is
correct (loads pointer/header value, then adds scaled index). Wired
at 6 sites: cstage cgassign N_INDEX-lhs plain ASSIGN + #133 compound
arm + cgindex N_INDEX read fallback; wwstage twin × 3. Closes the
silent-segfault on `(*struct).array_field[i]` reads and writes —
pre-fix cgexpr on the N_DOT base auto-derefed and loaded the field's
first 8 bytes as if they were a pointer, faulting on packed [N]u8
arrays (small u64 → unmapped page).
Bootstrap-NEUTRAL: zero working callers in either direction pre-fix
(symmetric READ + WRITE segfault evidence). All corpus + 990-997
byte-id + combined_ww_fresh stay green post-fix.
949_dotbase_arr_run: 3 rows direct runtime + cs==ww byte-id (READ
u8, plain WRITE u8, compound WRITE u8). Wider element widths and
value-struct base / pointer-field-control rows deferred — blocked by
orthogonal pre-existing wwstage divergences (i32-return ABI MOVSXD
vs MOVL, uninit-struct-let zero-init asymmetry) documented in the
test body. The TY_ARRAY-gate no-over-fire is implicitly verified by
994/995 (corpus exercises thousands of struct.pointerfield[i]
shapes; any over-fire would shift bytes).
Chained N_DOT (`outer.inner.array[i]` depth ≥2) deferred to #137 —
confirmed not in ref/hare/strconv/decimal.ha or sibling strconv/.
Not a fold-3 blocker; helper bails (returns false) on chained shape,
caller falls back to existing cgexpr path.
Both stages had silent miscompiles on compound assignment for two
shapes: indexed lvalue (`arr[i] OP= v`) and chained-pointer-field
(`d.fld.fld OP= v` through a *struct chain). The cstage N_INDEX-lhs
branch did not gate on TK_ASSIGN and silently DEMOTED compound ops to
plain stores (RHS stored, no load, no op). The wwstage equivalents
silently DROPPED the line entirely (no instructions emitted). The
chained-pointer-field compound template at cgen.c:3281-3317 also
silently identity-stored on unwired compound ops (SLASHEQ / PERCENTEQ /
LSHIFTEQ / RSHIFTEQ all fell to the switch default = no-op = load, pop
RHS, store ORIGINAL value back) and silently no-op'd on float / str /
slice / tagged element compound; its wwstage twin at cgenexpr.ww:5471
only handled TK_ASSIGN, dropping any chained-ptr-field compound
entirely.
Wire all 10 integer compound ops (PLUSEQ MINUSEQ STAREQ AMPEQ PIPEEQ
CARETEQ SLASHEQ PERCENTEQ LSHIFTEQ RSHIFTEQ) at all 4 sites in both
stages: SLASHEQ/PERCENTEQ via CQO+IDIVQ (signed) or zero-DX+DIVQ
(unsigned), with PERCENTEQ moving DX->AX for the result; LSHIFTEQ/
RSHIFTEQ via SHLQ/SHRQ on CX (rhs already in CX after the pop).
Signedness keyed off the field/element type via type_isunsigned /
typeisunsigned. Float / str / slice / tagged element compound now
LOUD-ERRORS at codegen with a distinct per-site diagnostic citing
#133/rule-7 instead of silent fall-through. Site 3 (the wwstage
chained-pointer-field compound) is ADDED FROM SCRATCH alongside the
existing TK_ASSIGN-only arm — pre-#133 wwstage emitted zero
instructions for any `d.i.v OP= v` shape, a rule-10 silent divergence
from the cstage which handled the same shape correctly.
Multi-fix carve-out (rule 11): the 10 wired ops at 4 sites + hard-error
gate on 4 unwired payload kinds at 4 sites are ONE silent-misbehavior
class closure on indexed/chained-ptr-field compound assignment.
Splitting would muddle bisect on related cgen surfaces — the wired
ops, the hard-error gate, and the rule-10 cstage/wwstage symmetry are
inseparable correctness facts at each site. The inherited template
default-break silent-identity (cgen.c:3281-3317) was the originating
class root; close it everywhere or leave the class open.
948_idx_compound_run: 21 rows total. 11 runtime+byte-id rows for the
original 6 ops on u8/i32/i64/u32 array bases and one slice base, with
a plain-assign control row asserting the ASSIGN path is byte-id-
unchanged. 7 new runtime+byte-id rows for SLASHEQ/PERCENTEQ on signed
i32 + unsigned u32, LSHIFTEQ on i32, RSHIFTEQ on signed-positive i32
and unsigned u32. 3 builderr rows (he_float_indexed, he_str_indexed,
he_float_chained_ptr) asserting both stages exit non-zero AND stderr
carries the cited diagnostic substring (rule-7 — never silent).
Mirrors 945_tuple_nary's builderr/experr pattern.
Bootstrap NEUTRAL — `grep -rE '\][[:space:]]*(\+=|-=|\*=|/=|&=|\|=|\^=|<<=|>>=)' lib/ selfhost/`
(excluding combined.ww) returns ZERO existing callers for the indexed
compound shape, and the chained-ptr-field compound shape was silent-
no-op in wwstage pre-fix (no working caller possible). 990-997 byte-
id gates green, 994 explicit confirms 18 corpus inputs identical
pre/post. combined.ww (w6c + wwdump) regen deterministic across
re-touch+rebuild.
A_SARQ is not in w6a's opcode table; signed RSHIFTEQ uses SHRQ at all
4 sites for parity with the pre-existing deref-lvalue compound site
(TK_RSHIFTEQ→A_SHRQ at cgen.c:4145). Documented technical debt
filed as #136 — pre-existing concern that a fix would need w6a
opcode addition + cgen sweep across every SHRQ-for-signed-RSHIFT
site, out of scope for this fold.
TK_STAR integer arm now routes through localloadop (cstage cgen.c) /
localloadop (wwstage cgenexpr.ww) — load-twin of the landed signed-
narrow-scalar-reads fix, was omitting TK_STAR. Closes the *p (CMPQ,
full-width arith) miscompile family (#116 + 962/963 instances all
fixed by the same width-aware load). Float arm untouched (#96 already
routed via X0). New test 947 (10 rows): packed CMPQ + signed/unsigned
narrow widths + TY_NAMED/TBANG alias + TY_ENUM peel + i64/bool controls.
Float array-element stores (array-literal init, [v...] repeat-fill, and
arr[i]=v) now route from X0 via MOVSS/MOVSD in both stages; the AX path
stored the raw double low-bits, garbage for f32 (f64 worked by accident).
A clobbering call-index (a[geti()]=v) loses the X0 value — deferred to #125.
cgindex's element-load sites ended in the integer loadopsz (MOVQ/MOVL
into AX), with no float branch — so an f32/f64 array element landed in
a GPR while the consumer's ADDSD/MOVSD read a stale X0. Add a float-
element branch (MOVSS f32 / MOVSD f64 into X0) at all three wwstage
cgindex sites (global, baselocal, fallback) and both cstage N_INDEX
element-load sites, deriving float-ness from the SAME stamped element
tinfo the esz already reads: new elemisfloatc/elemisf32c helpers
(mirroring elemissignedc) for ident bases, typeisfloat/typeisf32(n.type_)
for N_DOT/N_INDEX bases — never a fresh node-stamp that could hit an
unstamped base (#121).
The load fix cannot land alone: the wwstage consumer (cgbin/cgcast)
classified an indexed float operand as INTEGER (no exprfloatkind N_INDEX
arm) and fell to PUSHQ/ADDQ/MOVSXD, while the cstage read the stamped
operand type and used ADDSD/CVTTSD2SI. That divergence is pre-existing
on master (proven: master cs vs ww already differ on `a[0]+a[1]`),
contradicting the original "consumer already expects X0, cs==ww"
premise; load-only would leave the wwstage incoherent (value in X0,
consumed from AX) and still cs!=ww. So this also adds the exprfloatkind
N_INDEX arm — safe because the index-result type_ IS checker-stamped
(cgindex reads it for esz), unlike the unstamped-N_MLET case deferred
under #121. With both, f64 arrays are runtime-correct and both stages
emit byte-identical asm.
946_floatarr_run: f64 element add / trunc / non-adjacent index assert
the value + cs==ww; the f32 row asserts cs==ww only — its runtime value
is blocked by a SEPARATE store-side bug (f32 array-element store writes
AX raw double low-bits instead of CVTSD2SS-narrowed X0), filed as
#119-store. Regen w6c/wwdump combined.ww (cgenexpr.ww + cgenutil.ww
embedded).
Both stages materialise a float literal as a 64-bit double in X0 (MOVQ
bits -> MOVSD), ignoring the node type. For an f32-typed literal the
downstream MOVSS reads the low 4 bytes of that double — garbage (0.0f
for clean values, which is why 0.0 survived the bug and 951's f32 rows,
which only assert NaN ordering, never caught it). Append CVTSD2SS X0,X0
at both literal sites (N_FLOATLIT + the float-typed N_INTLIT arm) when
the node is f32-typed, so the value reaches X0 as a true single. Mirror
in cgenexpr.ww (rule-10) and regen the w6c/wwdump combined.ww embeds.
Covers literals carrying an explicit f32 type (the `f32` suffix and the
no-decimal `8f32` N_INTLIT arm). An un-suffixed literal in an f32
context (`let x: f32 = 1.0`) stays ty_untyped_float through the checker,
so its node is never f32-typed and this branch can't fire — that needs
fold-2 (checker untyped-float -> f32 lowering, both checkers).
964_f32lit_run: cstage run + cs==ww byte-id probe over concrete f32
values (suffixed), the hole 951 leaves open.
fold-1: type exists + classifies; mirrors TY_UINTPTR at every site, both stages. size(T)/len() return types UNCHANGED (fold-2). Regenerates the 5 combined.ww (lib/ww embedded).
A (f64,i64)/(i64,f64) tuple returns its f64 word in X0 (the SSE return
reg) and its integer word in an integer reg (tuple_rseq AX/DX). All three
tuple-from-call receive forms — single-var (cglet), destructure (N_MLET),
reassign (N_MASSIGN) — share the #83 tuple_rseq cursor and all spilled
the f64 word via MOVQ from the integer cursor; that reg holds garbage
(the float is in X0), and #103-FACE-Z's field read (MOVSD slot,X0) then
reads it. A single-return callee masked it (a float-literal return leaves
the f64 bits in AX, and X0 stays live); a branched callee with a non-
literal f64 word has an inner CALL clobber AX, exposing the corruption.
Make every receive spill class-aware: an f64/f32 word spills MOVSD/MOVSS
from X0 (the single SSE return reg, which survives the reg->mem stores
regardless of the word's position), an integer word spills MOVQ from its
tuple_rseq reg as before. cstage applies this at all three inline sites
(cglet, N_MLET, N_MASSIGN); wwstage at the cglet branch and in the shared
tupstore helper (covering cgmlet and cgmassign). The integer/str/slice
path is byte-identical to before, so bootstrap codegen is unperturbed.
Multi-float tuples collide on X0 at the RETURN (#107), out of scope here.
Two sites, same class: an f64 value failing to reach XMM (X0) before an
SSE op. Both gate-blind — cstage and wwstage emitted the same wrong asm —
so the fix touches both stages identically.
FACE X — a no-decimal float-typed integer literal (`0f64`, `8f64`) is an
N_INTLIT carrying float TYPE. The integer-immediate path stranded it in
AX, so `n == 0f64` compared a stale X0 (true for all n) and
`(8f64 * 10.0): i32` read garbage. Route the float-typed N_INTLIT through
the float-constant-in-X0 emit (cgen.c cgexpr_float, factored from
N_FLOATLIT; cgenexpr.ww cgfloatbits). The wwstage also needs the
exprfloatkind N_INTLIT arm so the downstream f64->i32 cast emits
CVTTSD2SI not MOVSXD — cstage reads the checker-stamped type directly,
so this is the same #101 structural-vs-stamped asymmetry.
FACE Z — a tuple positional f64 field read (`r.0`, r:(f64,i64)) loaded
via the integer op into AX, so `r.0 == 0.0` was wrongly true. Add a
fld_isfloat branch -> MOVSD/MOVSS into X0 (cgen.c:5910 tuple arm;
cgenexpr.ww tuple arm), mirroring the struct-field float load at
cgen.c:1462,1838 (the #96 pattern).
UCOMISD/UCOMISS set PF=ZF=CF=1 on unordered (a NaN operand). The old
arms keyed on ZF/CF only, so 4 of the 6 relops mishandled NaN:
`nan != nan` was false (JNE keys on ZF=0), `nan == nan` was true, and
`<`/`<=` (JB/JBE) fired on the unordered CF=1. IEEE-754: any relop
with a NaN operand is unordered — `!=` true, the rest false. `!=` now
jumps to true on JNE OR JP; `==`/`<`/`<=` jump to false on JP before
the ordered Jcc.
`>`/`>=` (JA/JAE) are LEFT UNCHANGED: they require CF=0, which an
unordered UCOMISD never produces, so they already reject NaN
correctly. Adding a PF guard there would only churn their .s (an extra
JP on every >/>= float compare) for no correctness gain, so their arm
stays byte-identical to the pre-#97 single template.
Bundles the cgen fix with JP-mnemonic support in both assemblers
(w6c enum/printer + w6a/w6a_ww parse+encode, 0F 8A). They can't split:
the cgen emits JP, which has no encoding without the assembler change,
so a cgen-only commit would not build. JP is the only PF-sensitive
jump on amd64 — there is no alternative instruction.
Replace the str-only XOR (e0_is_str ^ e1_is_str) at the tuple send
(N_RETURN) and receive (N_MLET/N_MASSIGN) sites with a positional
per-element register cursor, mirroring harec create_unpack_bindings
(ref/harec/src/check.c:1354-1416). Each element rides consecutive
eightbytes over [AX,DX,CX,R8]; a slice/str rides its 3-word
{ptr,len,cap} header (ref/hare/rt/ensure.ha:4-8), a scalar rides 1.
Send and receive walk the SAME type-table widths so element->register
agrees. This routes []u8 elements through the 3-word path (the XOR was
slice-blind, dropping len+cap to the scalar fallback) and closes the
pre-existing (scalar,slice) cs!=ww divergence by construction. cstage
and wwstage emit byte-identical asm.
Both receive sites derive each element's width from the rhs tuple's
element types (n->rhs->type->params / the callee return type) -- the
SAME producer view the send site walks -- NOT the binding type: a `_`
lvalue is an N_IDENT with empty str the checker never type-stamps, so a
binding-typed width mis-sized a wide `_` and desynced the cursor for the
next element (cstage read DX, wwstage R8). harec `_` skips the store but
CONSUMES its tuple offset; the cursor advance honours that.
Loud-stop (rule 7): the register file holds 4 eightbytes; a tuple whose
elements sum to >4 (([]u8,[]u8)/(str,str)=6) cannot be register-returned,
so the send site aborts at compile time citing the return-ABI capacity
(#10) rather than silently miscompiling. The receive loop guards the
same predicate (defense-in-depth). Routed through each stage's EXISTING
pinned-fatal idiom: cstage fatal() (cmd/wcc/err.c), wwstage the inline
os.write(2,...)+os.exit(1) at cgen.ww:604 -- no new diagnostics path.
N_MASSIGN (`a,b=f()`, bare comma, pre-declared) is a retained
ww-EXTENSION beyond Hare's binding-only tuple-unpack (Go/rob-pike
multi-assign, rule-9 carve-out); the loop covers it identically to
N_MLET.
Test 945_tuple_nary_destructure_run: (i64,[]u8)+(i64,str) store+read
len/cap for both N_MLET and N_MASSIGN, a single-str control, a wide-
first blank `_,a=f()` row (the cursor-desync discriminator), and a
([]u8,[]u8) row asserting the loud BUILDERR carries the cited
diagnostic; dual ww/ww_ww drivers.
A slice VALUE stored through a whole-deref lhs `*p = v` dropped len+cap:
the `*p = v` arm kind-gated its 3-word {ptr,len,cap} stash+store on str
ONLY, so a slice fell to the 1-word fldstoreop default (ptr only). The
deref READ is 3-word, so the reader got garbage len/cap -- correctness,
not perf. str IS []u8 since #1, so the str machinery applies verbatim;
widen the gate str -> str||slice (kind-OR, not a sz==24 test). This is
the project #75 str-only-gate one level down (deref-store).
cstage cmd/w6c/cgen.c:3792/3800 (two gates); wwstage cgenexpr.ww `*p=v`
twin detects N_TSLICE syntactically (mirror str). Both stages dropped
identically, so cs==ww + 990-997 + byte-id are all gate-blind here --
only a store->read roundtrip catches it. New 944_deref_slice_store_run
asserts the {ptr,len,cap} survives a poisoned dst, via the direct local
and the field-deref read; str-deref + (*p).field controls guard the
untouched arms. Verified fail-before (1-word ptr store) / pass-after
(8/8), byte-identical asm both stages.
Out-of-gate, deferred to #80: the wwstage syntactic detection is
alias-BLIND -- a slice-alias `*Foo` (Foo=[]T) or non-ident deref-store
stays 1-word, the SAME retained divergence str already carries (cstage's
resolved-type vt fires in both). #80 unifies detection by aligning the
wwstage UP, not gating cstage down. Separately surfaced (filed apart,
not touched here): the whole-deref READ-into-let `let v = *p` drops
len+cap for a slice while the str form is 3-word -- the read-side twin
of this store hole.
A sub-slice base[lo:hi] advanced its data pointer by lo (element
COUNT) instead of lo*esz (BYTES), so the base pointer was wrong for
any esz>1 element. Pointer arithmetic is membsz-unit per the rt
invariant (ref/hare/rt/ensure.ha:30); esz==1 (u8/str) is unchanged.
Four emission sites, fixed byte-identically across stages (rule 10):
- value path: cmd/w6c/cgen.c N_SLICE <-> cgenexpr.ww cgslice
- call-arg: cmd/w6c/cgen.c:4646 <-> cgenutil.ww pushargsrev
Scaling mirrors the cgindex idiom: esz from the type table (rule 13;
cstage bu->sub->size, wwstage elemsizeofc) gated to an N_IDENT base,
uniform IMULQ (no SHL special-case, no immediate form -- w6a is
reg-reg only). The live lo reg is the multiplicand so the one free
GP (DX value / BX arg) holds esz*lo; lo is preserved for len (hi-lo)
and cap (base_cap-lo, #20). The esz==1 path keeps the single ADDQ,
byte-identical to before (#75/#20/str unaffected). Non-ident bases
stay unscaled in both stages (wwstage has no tnode there), tracked
as a #76 residual alongside #74.
New 943_subslice_ptresz_run: table-driven, dual-driver (ww/ww_ww),
esz in {2,4,8} array+slice base, lo>0, let-form + call-arg form;
asserts s[0]==base[lo] & s[1]==base[lo+1]. Fails on every fixture
pre-fix on both stages, passes post-fix. Registered in Makefile
(TESTS + target) so test/run builds and runs it.
A sub-slice `base[lo:hi]` now sets cap to base_cap - lo (the storage
remaining to the underlying end; Go/Hare-identical) instead of hi - lo
(== len). base_cap is the array length N for [N]T, or the .capacity
word carried in a slice/str header at +16. Authored once per stage in
the cg_base_cap / cgbasecap helper, applied at both cap sites: the
N_SLICE value path (which serves let-init since the prior commit) and
the call-arg push. Both stages stay byte-identical (find-4 closed).
cap arithmetic per ref/harec/src/eval.c:1017 (slice: slice.cap -=
start) and eval.c:1024 (array: cap = array.length - start); capacity
is a distinct field per ref/hare/rt/ensure.ha:4-8 and cap >= len per
ref/harec/src/check.c:596. Only the cap arithmetic transfers: the ptr
stays unscaled (lo*esz is #76) and eval.c's stricter start>=end bound
is not ported (ww's runtime bound is start>end).
str[lo:hi] yields str with a real .capacity (D1), so the str base uses
the same +16 load -- no downgrade to []u8. base_cap falls back to len
(prior behavior) where it isn't cleanly available: a non-ident base
(its header cap was discarded by cgexpr; len is likewise wrong for a
defaulted hi there, pre-existing) and a global str base (wwstage
cgslice has no global-str load, #73 -- the carve-out keeps both
stages byte-identical).
Test: 942_subslice_cap_run, table-driven over both drivers, array /
slice / str base + an append-no-realloc row, each shape chosen so
base_cap-lo != hi-lo.
Fold in three pre-existing fixtures that asserted the old cap == len
and so failed under the corrected semantics (project #20):
681_arr_elem_field_write (slice_field_value_write,
slice_field_ptr_write, slice_field_distinct_bytes),
693_dot_tagged_source (local_struct_slice_variant,
via_ptr_slice_variant, letinit_slice_roundtrip, top_level_global_slice),
and 695_match_bind_struct (slice_neg_control). Each cap word updated to
base_cap - lo: a [8]u8 base sliced at lo=0 yields cap 8 (5->8, 3->8);
distinct_bytes slices a [16]u8 at lo=0, yielding cap 16 (6->16). len /
mark / ptr assertions are unchanged -- only the cap word moved.
Delete the vestigial inline slice-let builder in N_LET; a
`let s: []T = buf[lo:hi]` now routes through cgexpr's N_SLICE path
plus the generic 3-word store -- exactly as cstage's own str-let and
the wwstage already do. cap is unchanged (still hi-lo); the
cap = base_cap-lo fix is the following commit.
The builder duplicated cgexpr's N_SLICE base/hi dispatch and was a
strict subset of it, so for local bases the deletion is value-neutral
(ptr=base+lo, len=hi-lo, cap=hi-lo); only the routing bytes move,
aligning cstage down to the leaner wwstage and closing find-4
(rule-10). Verified byte-identical cs==ww across the slice-let matrix
{array,slice}x{local,global}x{hi-default,hi-explicit}.
Also fixes a cstage miscompile: the builder loaded a global-base
sub-slice via localfind->0 + BP-relative (no let_islet/masym), so a
`let s = G[lo:hi]` over a global array or slice G emitted
LEAQ/MOVQ 0(BP) garbage instead of the symbol address. Routing
through the global-aware shared path makes these correct
(ken-confirmed broken->correct).
The G-cluster gave str its 3-word {ptr,len,cap} store/read at indexed/field/chained sites, but each arm was gated on str only; the slice arm fell through to the 1-word fldstoreop default, dropping len+cap. A []T value stored through arr[i]=, arr[i].f=, *struct.f=, or value-spine o.i.f= (and read back via arr[i] / arr[i].f) silently lost length and capacity.
Widen all six arms (4 stores + 2 read mirrors) with a kind-OR (TY_STR||TY_SLICE / typeisstr||typeisslice), never a size test: str and slice are both 24B, so a width gate would fire on both and mask the missing slice arm. The str kind stays distinct and nominal -- the arm is widened, the kinds are not collapsed. cstage and wwstage mirrored.
Gate-blind class: store and read were both short, so byte-identity and cstage==wwstage stayed green on self-consistent garbage; only a runtime len/cap round-trip exposes it (test 941, table-driven, 4 shapes x 2 stages, fail-before/pass-after on both ww and ww_ww).
Deref store (*p=) and tuple-elem store (N_MLET/N_MASSIGN, distinct DX,CX,R8 return-ABI) are the same bug class but separate folds.
Post the str->24B lifts, cgassign had SEPARATE str and slice arms emitting byte-identical 3-word {ptr,len,cap} code. Collapse each identical pair into ONE kind-gated arm (rule 12, sea-of-stars; removes a drift hazard) -- the structural str==[]u8 unification, byte-id-NEUTRAL (each stage's emission unchanged for both str and slice inputs). Pairs: field store s.f=v + ident reassign name=v. cstage gates on the EXACT predicate union (raw kind==TY_STR OR'd with TY_SLICE -- NOT type_isstr, which would also match TY_UNTYPED_STR); ww on isstrtype||isslicetype and letvarisstr||letvarisslice (ww local field/reassign were already merged). Mirrors the in-tree deep-value-chain precedent (cstage 3274). str-only arms with no slice pair (arr[i].field=/chained, G1/G2) untouched.
Verified per-stage PRE==POST byte-identical (focused 5-path fixture + 4 large real combined.ww inputs, both stages); the 5 pairs were byte-identical pre-merge. main.combined.ww regenerated via the canonical make path (md5-stable). A pre-existing global-slice-field-store divergence (g.sl=b: cstage 3-word, wwstage 1-word) surfaced during review -- filed (#26/#10), NOT a C4.4 concern (PRE==POST).
F1 set str.sub = u8; the str-element-size readers no longer need a TY_STR special-case. cstage: delete the two 'if (kind==TY_STR) esz = ty_u8->size' blocks -- the general 'esz = sub->size' path already yields 1 for str (str.sub=u8), as the third index site (which never had the special-case) proves. Provably byte-id-NEUTRAL for ALL inputs: ty_str is the sole TY_STR instance and str.sub==ty_u8, so sub->size==ty_u8->size==1 in every case. No kind-gate (type_isstr/isstrtype arm-selectors) touched.
wwstage elemsizeof (cgenutil.ww) is COMMENT-ONLY: it names primtypesize("u8") directly because it operates on a raw type node with no stamped tinfo at the ident-base index path (str.sub lives on .type_.sub, unstamped here -- cf. cgforrange's 'if sti != nil'); that IS the str.sub-equivalent value, identical asm. Added the WHY + retargeted the citation to the surviving cstage path. The structural collapse there is blocked on tinfo-stamping, not intent -- filed (task #24); byte-id 990-997 guards the residual coupling.
Zero asm change both stages (cstage/wwstage .s byte-identical pre/post and cross-stage). main.combined.ww regenerated via the canonical make path (comment propagation only).
Reassign-destructuring a (scalar,str) tuple (a, s = call(), N_MASSIGN) stored only the str's ptr (DX->slot+0), dropping len/cap -- the last STORE-cluster gap. Reachable (valid ww; checker accepts str tuple elements) but unexercised in bootstrap (all N_MASSIGN sites returned <=8B tuples). Mirror the N_MLET destructure-store oracle (cgen.c:7475): on the one-str XOR, route the str's 3 words DX/CX/R8 -> slot+0/+8/+16; the slot pre-exists (localfind, not localadd). wwstage has no checker, so it derives str-ness from the callee return-type tuple via fnretlookupmod (structurally identical to cgmlet). Both XOR positions (str at l0 and l1). Kind-gated, never size==24. cstage==wwstage byte-identical.
Scope = one-str only, matching N_MLET exactly; str+str-both is unhandled by N_MLET too and is filed as a shared gap (task #22), with WHY-comments at both destructure sites. N_MLET emission unchanged (its edit is comment-only, verified byte-identical).
test/wcc/939: table-driven write-then-read-cap over both XOR positions (a,s=mk() and s,a=mk2()); cap!=len via mutation (not a sub-slice, #20); pre-poisoned via a non-G3 let-init; full triple+scalar asserted; fail-before/pass-after on both drivers. Completes the str-cap STORE cluster -- the read/write round-trip is now whole. main.combined.ww regenerated via the canonical make path.
Storing a str into a field reached through a *struct-valued expression (e.g. r.sym.flag = v) wrote only 2 words (ptr,len), dropping cap -- the second STORE-cluster fold. Direct transfer of G1 (c692923): the prior arm spilled only ptr/len across the base eval; now spill the full value (PUSHQ CX/BX/AX) after the rhs eval and before the base-expr eval (the stack slot insulates it, base-formation-agnostic), stage the *struct ptr in DX (off the AX/BX/CX str convention), store ptr/len/cap at foff+{0,8,16}. Mirrors the s.f=v oracle (cgen.c:2603); G2 adds the spill the oracle skips because the oracle's base is a slot read, not a clobbering expr. Kind-gated (TY_STR/typeisstr, never size==24). cstage==wwstage byte-identical at the store site.
test/wcc/938: table-driven write-then-read-cap over depth-2 (r.sym.f=) and depth-3 (r.a.b.f=) chained bases, both asm-confirmed to hit the chained arm. rhs is a cap!=len str; all 3 slot words pre-poisoned via a non-G2 direct store; full {ptr,len,cap} triple asserted. fail-before/pass-after verified on both drivers.
main.combined.ww regenerated via the canonical make path (md5-stable).
Storing a str into a field of an indexed element (arr[i].f = v) wrote only 2 words (ptr,len), dropping cap -- the write-side mirror of the arrfield read (c3bbe17), and the first STORE-cluster fold. The trap: the index scale (IMULQ via CX) clobbers CX=cap and the index-expr eval clobbers AX=ptr before the store. Fix composes two proven oracles -- arr[i]=v (cgen.c:3650) spills the value (PUSHQ CX/BX/AX) across the index/address computation, then s.f=v (cgen.c:2603) stages the dst address in DX (off the AX/BX/CX str convention) and stores ptr/len/cap at foff+{0,8,16}. Kind-gated (TY_STR/isstrtype, never size==24). cstage==wwstage byte-identical.
test/wcc/937: table-driven write-then-read-cap over [N]S / []S / [N]*S arr[i].f= ; rhs is a runtime cap!=len str (not a literal, which would be cap==len); all 3 slot words pre-poisoned via a DIFFERENT already-3-word store path so a stale 2-word store is detectable; asserts the full {ptr,len,cap} triple. Meaningful only now the reads are 3-word. fail-before/pass-after verified on both drivers.
main.combined.ww regenerated via the canonical make path (md5-stable).
A str-typed field read of an INDEXED element (arr[i].f) loaded 2 words (ptr,len), dropping cap -- the last 2-word str VALUE-read in the cluster. At the leaf the element base is always in AX; insert cap->CX at foff+16 (final order len->BX+8, cap->CX+16, ptr->AX+0 LAST). ONE shared leaf covers value-array / slice / pointer-element sub-cases (base-formation differs upstream, unaffected). Author-to-ABI, matched to the proven cgslicehdr(D_AX) / caseB slice-arm shape. Kind-gated (TY_STR / isstrtype, never size==24). cstage==wwstage byte-identical at the leaf.
test/wcc/936: table-driven runtime .cap-survives over [N]S-local / []S-local / [N]*S-pointer-elem reads; a 2-word read cannot coincidentally pass (the index scale-multiply clobbers CX, plus an interposed call). fail-before/pass-after verified independently on both drivers.
main.combined.ww regenerated via the canonical make path (md5-stable). Completes the str 3-word VALUE-read cluster (F2 element; C4.6/caseB/S3 fields; arrfield indexed-field). Store-side cap-drop and a struct-slice-creation divergence are separately filed.
Reading a str-typed tuple element by position (t.N) loaded 2 words (ptr,len), dropping cap -- the C4.6 coda with NO adjacent slice-element arm to mirror. Author the 3-word triple directly to the canonical {ptr,len,cap} ABI (AX,BX,CX off the BP frame slot; add cap->CX at +16). Kind-gated (TY_STR / isstrtype, never size==24). cstage==wwstage byte-identical -- cross-stage identity is the correctness oracle here, since there is no local slice sibling.
test/wcc/935: table-driven runtime .cap-survives over let s = t.1; poison rides the return ABI (R8) into the slot's cap word; a CX-clobbering call ensures a 2-word read cannot coincidentally pass. fail-before/pass-after verified INDEPENDENTLY on both stages.
main.combined.ww regenerated via the canonical make path (md5-stable).
The chained N_DOT path (o.p.f, depth>=2, base AX) still loaded a str-typed field as 2 words, dropping cap -- the C4.6 sibling deferred to caseB. Fold the str case onto the adjacent 3-word slice-field arm (widen kind-gate: cstage type_isstr, ww typeisstr; never size==24). Emits len->BX+8, cap->CX+16, ptr->AX+0 LAST (AX is the base). cstage==wwstage byte-identical; the slice arm is unchanged for slices.
test/wcc/934: table-driven runtime .cap-survives over the chained read; the row interposes a CX-clobbering call so a 2-word read cannot coincidentally pass on stale CX (per the 933 discriminator lesson). Verified fail-before/pass-after on both drivers.
main.combined.ww regenerated via the canonical make path (md5-stable).
Reading a str-typed struct field loaded only 2 words (ptr,len), dropping the cap word. Fold the str-field read onto the adjacent proven slice-field arm by widening its kind-gate to include str (type_isstr/isstrtype, never size==24). Sites: S1 direct struct field (local BP + global CX base) and S2 field through a *struct local (pst.f). cstage==wwstage byte-identical; the slice-field arms stay unchanged for slices.
C4.6 bundles the S1 local-field fold with a FORCED global-field lift -- the rule-11 reason they cannot split: cstage reads a field with ONE unified base_reg arm, so folding str covers local AND global together. For byte-id, ww's global field path must then lift in the SAME commit -- but ww splits local/global and its global arm has no slice sibling, so it is authored as ww's own local slice-field arm retargeted to the CX base (cap->CX last, base survives). The underlying cstage-unifies / ww-splits field-arm divergence is a separate filed structural follow-up, not resolved here.
test/wcc/933: table-driven runtime .cap-survives over local/global/*struct field reads, both drivers; verified fail-before/pass-after. The local-field row interposes a CX-clobbering call so a 2-word read cannot coincidentally pass on stale CX (the field store otherwise leaves the cap word lingering in CX).
main.combined.ww regenerated via the canonical make path (md5-stable), per the 1140a59 precedent.
str element value read at N_INDEX dropped the cap word (2-word ptr,len load); str is 24B {ptr,len,cap} since 1140a59. A new named helper cgslicehdr (both stages) loads the full 3-word header and is called by the N_INDEX str-element sites, kind-gated type_isstr/elemisstr -- never size==24, since str and slice collide at 24B. The base-targeting word loads last (clobber-safe). cstage==wwstage byte-identical. The #9 typeassert leaf is split out to F2b (it needs a wwstage spill twin first).
test/wcc/932: table-driven runtime .cap-survives probe over both N_INDEX base forms and both drivers; verified fail-before/pass-after. NNN<950 mirrors the 928 precedent -- the fixtures are self-contained (/tmp, no imports), so rule-14's selfhost-sibling race does not apply.
main.combined.ww regenerated via the canonical make path (md5-stable) and committed alongside source, per the 1140a59 precedent.
Phase 2 step 4. The str and slice tagged-union payload stores were
byte-identical adjacent arms (3-word ptr/len/cap @ slot+8/+16/+24 +
tag) since #1 made str a 24B {ptr,len,cap}. Delete the dedicated str
arm and widen the slice arm's gate to accept str (cstage type_isstr,
wwstage nodeisstr). One site, both stages. Byte-id-neutral: str now
flows the identical slice arm; full test incl 990-997 green.
Phase 2 step 2. The N_INDEX element-stride for str hardcoded esz=1;
route it through the type table (cstage ty_u8->size, wwstage
primtypesize("u8")) so str's element width tracks the u8 SSoT. When
the TY_STR collapse (Phase 2 step 5) folds str onto the slice-of-u8
path, these sites need no second edit. Byte-id-neutral: u8 size is 1,
so emitted stride is unchanged; full test (incl 990-997 byte-id
gates) green.
A ww `str` becomes a 24-byte {ptr,len,cap} value, identical in layout to
[]u8 -- the enabling prerequisite for the Phase 2 `str == []u8` collapse.
Both stages, atomically:
- ty_str 16->24B; str value flows 3-reg AX/BX/CX (was 2-reg); str literals
emit cap (=len).
- str in a tagged union grows to a 32B slot, using the AX/DX/CX/R8 4th-word
path already used by 32B slice-variant unions -- str-variant is now
structurally identical.
- tuple (scalar,str) return: 4-reg AX/DX/CX/R8 + 32B receive, extending the
existing type-keyed return (no sret).
- str == []u8 for index and .ptr/.len/.cap, kind-gated where size-based
dispatch collided at 24B; cstage and wwstage mirror exactly.
- table-driven runtime coverage: test/wcc/928_str_abi_run.c.
Cannot be split (rule 10/11): a 24B str and a 16B str cannot coexist across
the two compiler stages without breaking byte-identity, so the size change
and every dependent ABI/codegen site land in one atomic commit, both stages.
Known follow-ups (zero corpus impact, tracked): str-literal global .cap
static-init; >16B struct by-value (pre-existing); tagged-union
match-scrutinee stage divergence (pre-existing).
Hare's canonical runtime allocator is rt::malloc with linker symbol
rt.malloc (ref/hare/rt/malloc.ha:27,78). ww kept the dot→underscore
Plan 9 convention (CLAUDE.md rule 4) so the linker symbol becomes
rt_malloc; the lib/rt exported function name becomes malloc; ww
callers say rt.malloc(...).
The language builtin keyword stays `alloc(T)!` — unchanged from Hare
(ref/hare/hare/lex/token.ha:21 ltok::ALLOC, parse/expr.ha:398
builtin()). The rename only touches the lowered linker symbol and the
exported function name behind it; the user-facing syntax for
heap-allocation is identical to Hare.
Surface:
- rt/alloc.s: TEXT rt_alloc → TEXT rt_malloc, labels updated
- lib/rt/malloc.ww: @symbol("rt_malloc") fn malloc(...) (was rt_alloc/alloc)
- rt/ensure.ww: local FFI decl + call site updated to malloc; `!` dropped
on the direct FFI call (rt_malloc returns *void, not a tagged union)
- 18 .ww callers: rt.alloc(...) → rt.malloc(...)
- cstage cmd/wcc/check.c + wwstage selfhost/cmd/wcc/check.ww
alloc-builtin suppression gate routes through ffi_resolve("malloc")
for the lowering; the user-shadow check still keys on the BUILTIN
KEYWORD "alloc" since that is what `alloc(...)` parses as. Adding
"malloc" to the user-shadow check was unnecessary and was reverted
during pre-commit review.
- cstage cmd/w6c/cgen.c: 2× ffi_resolve("alloc") → ffi_resolve("malloc")
- wwstage cgenexpr/cgenstmt: 2× ffiresolve(c, "alloc") → ffiresolve(c, "malloc")
- Test fixtures (700_e2e, 758_cgalloc_str_field, 990_selfhost, 992_w6l_ww,
selfhost/test/tagged_ptr_ret.ww): updated inline ww sources to the new
decl + call form
This is commit 2 of 3 in the lib/rt extraction (#38). Commit 3 closes
the OOM contract — return type becomes nullable *void and the builtin
lowering null-checks + propagates nomem.
Verified 132/132 + 995_self_rebuild byte-identity (5 wwstage tools
round-trip identical) + make clean cold rebuild.
#43 (8e93b31 + 087c85c) routed many sizeof(str) / sizeof(slice)
sites through primtypesize / tyslicesize / ty_*->size, but missed
the cgparam regs-fit, cgparam stack-stitch, cgmlet mixed
scalar+str receive, and vararg slice gather paths in both stages.
A bare #1 bump (str→24B) on top of #43 reds ~60 tests because
those paths still hardcoded 16/24.
Cstage:
- cgen.c:7360-7361 cgmlet: sz0/sz1 → (int)u0->size / (int)u1->size.
- cgen.c:7557 cgparam regs-fit: slice|is_str → (int)pu->size.
- cgen.c:7586 cgparam stack-stitch: same.
- cgen.c:4368 cgcall vararg gather: localoff slice descriptor →
(int)vsu->size (the cstage twin of cgenexpr.ww:3084).
Wwstage:
- cgendecl.ww:225, :243 cgfnparams: 16 → primtypesize("str"): i32.
- cgenexpr.ww:3084 cgcall vararg gather: 24 → tyslicesize(): i32.
Plus a latent-bug fix at cgenstmt.ww cglet :1031 / :1040: the
str-init and slice-init arms dispatched on size only. Under #1's
str→24, both arms would have fired on a str let (duplicate
MOVQ BX,off+8 + bogus MOVQ CX,off+8). Added isstrtype / isslicetype
kind gates mirroring cstage cgen.c:6439's
`type_isstr(lt) && sz == ty_str->size`. Zero asm change today
because the size constants implicitly disambiguate at 16 vs 24.
Probe with temporary #1 bump (str.size=24) confirms 990_selfhost +
994_w6c_ww go green — the cgen-routing slice for #1 is now
closed. Remaining red under bump is lib/ww/typ.ww's parallel SSoT
seed + stringstest cap*16u64 strides + w6l_ww runtime SIGSEGV;
all tracked separately.
EIGHTBYTES register-count sites (cgen.c:7553-7554, cgendecl.ww:224
/:260) intentionally NOT touched — those are str ABI in-flight
3-reg work (task #34), not slot-width SSoT.
Audit §1.1/§1.2 cataloged 17 wwstage sites hardcoding 16 for sizeof(str)
and ~10 hardcoding 24 for sizeof(slice), plus 4 cstage str-size sites
and the cstage let_emit_size str/slice arms. Each new size constant
required ~30 edits in both stages to bump cleanly — task #1 (str → 24B
{ptr,len,cap}) can't land until the literal sweep is done.
Track A — wwstage codegen (selfhost/cmd/wcc/*):
- check.ww introduces two stateless helpers next to astsize:
primtypesize(nm) — primitive-name → byte size (i64; -1 unknown)
tyslicesize() — slice-header bytes (i64; 24 today)
astsize now reads both for its N_TNAME-primitive and N_TSLICE arms,
so the size(T) fold gets the SSoT for free.
- cgen.ww, cgenutil.ww, cgenstmt.ww, cgendecl.ww: every `return 16`
/ `esz = 16` / `sz0 = 16` for str, every `return 24` /
`localadd(c, _, 24, _)` for slice, plus the matching `sz == 16` /
`sz == 24` / `for (i < 16/24)` gates in the global-let DATAW emit,
route through primtypesize / tyslicesize.
- Direct delegation slotsize→astsize would require restructuring
astsize to drop its *checker dep (resolvealias) — the leaf
primitive/slice cases factor out cleanly, the alias-chain leaves
diverge because cgen's aliaslookup/structlookup tables and check's
scope chain aren't unified yet (§1.8, task #50 follow-up). Sharing
the leaf table satisfies the SSoT promise without that refactor.
Track B — cstage (cmd/w6c/cgen.c):
- let_emit_size's TY_STR/TY_SLICE arms drop the hardcoded 16/24 and
fall to `(int)u->size` like the existing TY_STRUCT/TUPLE/TAGGED arms.
- N_LET cgstmt's per-kind `sz` cascade collapses to a single
`if (lu->kind ∈ {ARRAY,SLICE,STR,STRUCT,TUPLE,TAGGED}) sz = lu->size`.
- N_LET cgexpr's match-bind primitive sizing: `bsz = (int)bu->size`
drops the TY_STR/TY_SLICE special-cases (same outcome — ty_str/
ty_slice already have ->size set by type.c).
- Three `sz == 16` / `let_emit_size(d->type) != 16` gates against the
str slot width route through ty_str->size.
Cap-offset sites (cgen.c:2440/1994/3206/5517 `delta = 16` for
slice's .cap field-write) intentionally NOT touched: 16 there is the
*offset of .cap inside a slice header*, structurally always 16
regardless of str.size. #1 doesn't move the slice layout.
Track C — lib/ user code:
- lib/strings.freeall + appendstr, lib/shlex.freepartial + appendstr:
the four `16u64` literals (per-str-element stride for rt_ensure and
os.free) become `size(str): u64`. Check-time fold via #42's
intercept resolves to 16 today; #1 reroutes via the bumped tinfo.
After this commit, bumping ty_str to 24B for task #1 requires editing
exactly two places (cmd/wcc/type.c:64 ty_str.size, plus check.ww
primtypesize's "str" arm) for the SSoT to propagate.
Verification:
- 131/131 tests pass. 994_w6c_ww + 995_self_rebuild byte-identity
holds — each replacement evaluates to the same constant the
literal had today, so cgen output is unchanged.
- selfhost source's `size(str): u64` folds at check time (cstage
cmd/wcc/check.c:907-960 for the C-bootstrap of selfhost; wwstage
check.ww:898-942 for the rebuild path), no runtime call introduced.
`alloc([], n)` synthesizes ([]u8 | nomem) at expression level — that's
fine, since the slice form only legitimately appears in let-init
position where the LHS carries the real element type. In clet, after
type-checking the rhs, peel any N_TRYPROP/N_TRYUNW wrapper, match the
alloc-slice AST shape with the same-module shadow gate (from #23),
and retype the call's tagged return to ([]T | nomem) where T is the
declared LHS element. Then assignability sees []T vs []T and accepts.
Cgen N_LET shortcut gains a viatryprop arm next to the existing
viatryunw — on rt_alloc returning null, emits the tagged-return
nomem propagation (MOVQ $nidx, AX; epilogue) instead of exit(1).
nidx comes from cg_tag_for_variant on the enclosing fn's return type,
matching the existing TRYPROP propret path.
Wwstage mirrors all four hunks (check.ww + cgenstmt.ww). Promotes the
previously-silent conf=false skip into a confident accept.
Unblocks #6 (dupall) and lays the path for #4/#7. Byte-identity
holds modulo the pre-existing #44 alloc/rt_alloc symbol divergence.
Per Hare convention, alloc is a typed builtin that returns a tagged
union carrying nomem as the OOM variant. Callers spell their policy:
`alloc(T)!` aborts on OOM (the old behavior), `alloc(T)?` propagates
when the enclosing fn already returns nomem.
cstage: check builds TY_TAGGED{*T | nomem} (or {[]T | nomem}); cgen
emits AX=tag, DX=ptr per the general tagged-return ABI (the (*T|!void)
nullable-ptr fold gated in ea76ee4 keeps this clean). wwstage cgalloc
mirrors. rt/alloc.s zeroes AX on syscall error so the builtin's null
check sees a clean 0 instead of mmap's -errno leaking through as a
poisoned pointer.
Migration: 3 `!` sites in test/wcc/700_e2e.c, 1 `!` site in
rt/ensure.ww (preserves the pre-existing sizeof bug tracked by #27),
1 `?` site in selfhost/test/tagged_ptr_ret.ww (allocbox exercises
real `?` propagation against a (*T | nomem) return).
130/130 tests green, 994_w6c_ww + 995_self_rebuild stage byte-identity
preserved. Follow-ups #31 (wwstage checkletassign leniency), #32
(wwstage slice-form gap), #33 (tagged_ptr_ret.ww make-test wiring).
Mirrors the existing abort/assert gates in cstage check.c (strict
same-module lookup rather than scope_lookup_prefer, since lib/os.alloc
under a `use os;` import must not suppress the bare-alloc builtin in
client code). cgen.c shadows the resolution: only fire the rt_alloc
path when the typer left N_CALL.lhs->type == ty_err. wwstage gets a
new samemodfn helper for the matching gate.
Test fixtures: package-main repair for the 3 alloc rows in 700_e2e.c
that the parser was inheriting curmod="os" from the concat'd os.ww;
new shadow-test row asserts a same-module `fn alloc(n: i64) i64`
beats the builtin in cgen.
Per CLAUDE.md rule 10, align cstage down to wwstage — when every
variant in a `?` propagation maps to itself, the remap loop emits
zero JMPs and the propret label is dead. Lazy-allocate it so the
label-counter ID is only consumed when at least one JMP fires.
Smoke test selfhost/test/trypromote.ww exercises same-shape
(i64|nomem)→(i64|nomem) propagation; cstage and wwstage now emit
byte-identical asm for the TRYPROP region.
Single-peel TY_NAMED.under bottoms out at the inner alias when
chain length is 2+, surfaces in two stages with different
mechanisms: cstage's gates inline `if (t->kind == TY_NAMED)
t = t->under` at every callsite (cgreturn, cglet sizing, cgexpr
N_DOT, cgassign N_DOT, cg_sret_retsize) — graduated to a
while-loop via new type_chase_named helper across 11 sites.
wwstage routes all field-walks through structlookup, which
registers only direct struct definitions (not aliases) — missing
the alias-recurse fallback. New structlookupchain helper mirrors
slotsize's N_TARRAY arm precedent; sretretsize + 4 cgenexpr.ww
sites route through it. Splitting would either land cstage
without unblocking wwstage's strings.tokenize wrapper shape
(rule 10 byte-id regression) or land wwstage without cstage
gate parity (breaking 995 self-rebuild). 756 sentinel exercises
4 rows × cstage RC + wwstage RC + byte-id = 12 fixtures; pre-fix
rows 2 + 4 (slice-fields single alias, i32 double alias) fail
on both RC and byte-id. The ~67 cstage / ~26 wwstage candidate
sibling sites are #17-style structural-close follow-up; this
commit fixes the immediate strings.tokenize-wrapper blockers.
Latent silent miscompile: cstage + wwstage mklabel emitted
<fn>_<prefix>_<seq> with no module qualification, so two top-level
fns sharing a leaf across modules (e.g. bytes.index + strings.index)
emitted colliding labels into the same combined .s. Last assembler
symbol-definition won; JNE/JMP rel32 resolved to the wrong fn's body.
Repro (HEAD pre-fix): two_modules_same_leaf row in 750 — mod1.locate
+ mod2.locate sharing match-over-(u8|[]u8)+for shape. mod1.locate's
JMP misresolved into mod2's body, exit 10. Post-fix: exit 0.
Latent already at HEAD: bytes.contains_match_next_1 +
strings.contains_match_next_1 collide today but the corpus had no
forwarding path that surfaced it.
cmd/w6c/cgen.c + selfhost/cmd/wcc/cgen.ww mklabel: prepend
<module>. when c->cur_mod / c.curmod non-NULL/non-empty. Plan-9
convention extension: TEXT directive already uses <module>.<fnname>
(lex.c:18 a_isidcont accepts '.'); mklabel now mirrors that for
local labels. Both stages symmetric per rule 10. Fragment input
(no `package`) collapses to pre-fix shape — no cross-unit risk.
750_mklabel_modscoped: table-driven 3 rows x 2 stages = 6 sub-cases
(two_modules_same_leaf, bytes_strings_contains, same_module_same_leaf
non-regression). All required substrings asserted via grep + runtime
rc check.
make test 124/124; ww2==ww3==ww4 byte-id holds via 995_self_rebuild.
@-prefix slot keys (cg_tagbase, cg_tagscr, @retscr) are orthogonal
(local_alloc keys, not mklabel emissions).
Subsumes #36. Drop wwstage scanlocals pre-pass; both stages converge on
first-use+fail-loud frame growth, rule-10 polarity DOWN to leaner side.
#36's surfaces (frame-total divergence on match-arm case-let; sibling
offset divergence in variadic+iter+match-prev compositions) close
naturally — running-max c.frame includes every first-use binding.
selfhost/cmd/wcc: add atlocals persistent @-prefix registry surviving
cgblock save/restore; add cgoutbuf/cgoutmode/cgout_enable/disable/flush
for deferred prologue (emit body to buffer, finalise c.frame, then
TEXT/SUBQ + flush); localadd @-prefix dedups against atlocals +
fail-louds on size-grow (rule 7 — no silent truncate); cgreturn-tagged
routes through @retscr (was colliding with @tagscr on arg-widen sizes);
variadic gather esz uses raw primsize (rune->4) not slotsize (rune->8)
— matches cstage and fixes the #36 sibling runtime miscompile in
non-leaf variadic+iter+match-prev callees.
cmd/w6c/cgen.c: drop the over-allocation hack ("for byte-id with
wwstage scanlocals reservation") since wwstage no longer over-reserves;
add fail-loud on @sretscr size-grow; @tagscr sites pass actual slot_sz
instead of stale c.tagscrsz.
748_size_strategy_convergence: table-driven 4 rows x 2 stages
(tag_variadic_runearm, trim_iter_match_prev, variadic_gather_rune_stride,
leaf_baseline). Each exercises a #36 surface shape; 8/8 ok.
Net -1565 lines. Sister latents filed as cosmetic (cs/ws frame size
drift on multiple-variadic-call fns): labelseq drift + varargseq
stuck at 0 — both bootstrap-byte-id safe (ww2==ww3==ww4 holds since
both ww2 and ww3 are wwstage outputs).
make test 122/122; ww2==ww3==ww4 byte-id holds via 995_self_rebuild.
cstage Sdef walk #2 N_DOT branch used c->cur_mod where n->lhs->str is
the correct module hint. Sister of #4c wwstage graduation; same shape
as the TY_FN branch which already uses mafn(c, n->str, n->lhs->str).
cmd/w6c/cgen.c: add sdef_mod_match_hint(s, hint); walk #2 routes hint
first then head-pick fallback, matching #4a/#28/#31/#34 *mod variant
pattern. selfhost: add deflookuprhsmod(c, name, mod); cgdot N_DOT
mod-qualified str-def value-load routes through it. Rule-10 symmetric
stages: both stages now share the lhs.str polarity (was: both used
cur_mod / cur-module hint).
747_def_modqual_modshadow: table-driven sentinel — gamma calls
alpha.MSG with beta.MSG (same-leaf-name) at head of c.defs/sdefs.
want_imm "$38," (alpha strlit len), bad_imm "$27," (beta strlit len),
plus cs-vs-ws byte-id. Reverting cstage walk #2 to head-pick → fails
$38 on cstage + diverges cs-vs-ws; reverting wwstage cgdot to plain
deflookuprhs → fails $38 on wwstage.
make test 121/121; ww2==ww3==ww4 byte-id holds.
let p2: T = p1; where T is a struct >8B and rhs is a local ident
silently dropped most of the copy. Cstage's N_LET fell past every
specialized rhs branch (str/tuple/tagged/structlit/call) without
matching the bare-ident case, then past the sz==8 fallback (false)
to the no-rhs zero-init (false: rhs present), emitting zero
instructions — the dest slot read fresh-stack zeros. Wwstage's
cglet fell to cgexpr+MOVQ AX which loads only the first qword
(cgident shape for struct ident), and for sz==16 slots the
str-init tail then stored a stale BX into +8. Reads after the
let saw whatever the stack held: silent partial copy.
Both stages now byte-copy src slot → dst slot per qword with
a sized tail (MOVL/MOVB) for natural sizes not 8-aligned.
Mirrors cg_widen_tagged_store's struct-ident payload copy.
744_letcopy_struct pins the four struct shapes (3×i32, i32+str,
i32+[]u8, i32+tagged) on asm-presence in both stages, cmp -s
byte-id, and runtime exit code via both drivers.
Scope: only N_IDENT rhs at the local-ident-found path. Filed as
siblings (no in-tree consumer today, bootstrap byte-id proves it):
- N_DOT / N_INDEX / N_UN(deref) struct rhs.
- Top-level (non-local) struct ident rhs.
- TY_TUPLE same-shape ident-copy bug.
Row (a) uses tri{a=11, b=22, c=33} structlit init for p1 to
isolate this fix from STATUS-3 #15/#26c (no-rhs zero-init sz=12
vs sz=16 slot-padded divergence between stages, separate task).
Row (d) runtime check uses only p2.a to isolate from match-on-
tagged-field scrutinee spill divergence (same task).
118/118 ok. ww2 == ww3 == ww4 byte-id holds.
cstage variadic gather stored only AX (.ptr) per element; .len and
.cap read stack residue at the callee. Tagged-union variadic path
escaped because cg_widen_tagged_store wrote the full slot — but
primitive-type variadics (str..., slice...) silently dropped the
trailing fields. Selfhost only uses tagged-union variadics
(formattable...) so bootstrap byte-id ww2==ww3==ww4 stayed green;
the bug surfaced in worker-strings pre-flight (session 5) on the
Hare-faithful concat(strs: str...) shape.
Per-element store branch now mirrors selfhost/cmd/wcc/cgenexpr.ww
velemstr (AX→slot+0, BX→slot+8) and velemslice (AX→slot+0,
BX→slot+8, CX→slot+16). Also swap dname-before-sname allocation
order in the variadic-pack frame layout to match wwstage scanlocals
+ localadd order (cgendecl.ww:507-516 and cgenexpr.ww:2949-2954);
without the swap post-fix asm has correct stores at mismatched
offsets vs wwstage.
Rule-10 alignment: cstage UP to wwstage's already-correct primitive
variadic path.
743_variadic_pack pins the contract: asm-presence ≥3 ptr-stores +
≥3 len-stores in caller TEXT on both stages, plus cs-vs-ws cmp -s
byte-id per row. 117/117 ok. Bootstrap byte-id ww2==ww3==ww4 holds.
Unblocks: lib/bytes contains-variadic, lib/strings sub variadic,
and the concat/trim/contains family that c1 shipped non-variadic.
Class A frame-layout landmine pre-located; #26c queued for size-
strategy convergence per rule 10.
Cstage's tagged-scratch sites previously stamped per-call labels
via mklabel "tagbase"/"tagscr"/"argscr"/"idxscr", bumping labelseq
once per call and allocating a fresh frame slot. Wwstage routes
the same sites through localadd("@tagbase", ...) and
localadd("@tagscr", c.tagscrsz, nil) — the @-dedup shares ONE
slot per name per fn and never touches labelseq. @tagscr is
shared across THREE wwstage sites: cgenutil.ww:180 pushargsrev
struct-payload widen, cgenutil.ww:2918 cgwidentaggedstore
via_outer, cgenexpr.ww:3524 cgindex tagged-element. Worker's
initial draft introduced cg_argscr / cg_idxscr as separate
cache vars — names that don't exist in wwstage. Per rob's rule-10
amendment those collapsed to a single cg_tagscr shared across
the 3 sites, matching wwstage's @tagscr SSoT exactly.
Cstage now caches two slots matching wwstage's namespace exactly:
cg_tagbase (8B base spill, 1 site at cgwidentaggedstore via_outer)
and cg_tagscr (sized scratch shared across the 3 sites above).
Eliminates per-call labelseq bumps and per-call frame churn.
Class A byte-id drift (silent corpus-coverage-blind landmine)
closed for the 1-name shape match. Model: STATUS-3 #15 commit
987391b routed @retscr through the same SSoT via cg_retscr;
this commit extends the carve-out to @tagbase and @tagscr.
Size strategy: cstage has no scanlocals pre-pass (wwstage's
c.tagscrsz pre-pass at cgendecl.ww:32 tagscrbump computes the
per-fn max). First call across the 3 @tagscr sites sizes the
slot; subsequent calls reuse if sz <= cached, fatal() if larger
(rule 7: surface-don't-silently-corrupt). Long-term rule-10
convergence — wwstage DOWN from scanlocals to first-use+fail-loud
on BOTH stages (per rob: aligning richer DOWN to leaner) — is
filed as #26c, separate concern from #26's name-SSoT graduation.
Tests:
- 736_cstage_label_ssot succ_rows: pins cstage-vs-wwstage cmp -s
byte-id on the canonical pointer-rooted two-tagged-store shape
(two `c.v = (...: bag);` writes through *cell). Pre-fix cstage
frame was 16B+48B larger (2*@tagbase + 2*@tagscr per call);
post-fix single-slot SSoT matches wwstage byte-for-byte.
- 736_cstage_label_ssot fail_rows: pre-locates the size-grow
landmine. A fn with two unions of different slot sizes (16B
then 24B) routed through @tagscr; cstage must fatal() with
"@tagscr cached sz" + size mismatch + #26c follow-up cite.
Gates corpus growth into this shape against silent miscompile.
110/110 ok. 995_self_rebuild byte-id holds (ww2 == ww3 == ww4).
Class A silent miscompile, latent until two modules export the same
str-typed def leaf name and the .ptr/.len field-fold path consumes
the wrong-module strlit address/length. Wwstage's deflookuprhs
(selfhost/cmd/wcc/cgen.ww) walked c.defs head-first by dname; cgdot's
.ptr/.len field-fold handed it the bare leaf from N_IDENT.str,
silently inlining the wrong-module strlit. Cstage carries the same
shape at cmd/w6c/cgen.c (Sdef walk #3 N_DOT field-fold): Sdef keyed
by name only, head-pick on every cross-module collision. No in-tree
corpus declares two same-leaf str defs, so 995_self_rebuild stayed
green (same surfacing pattern as #4a enumlookup post-strings and
#4b structlookup).
Sixth leaf of the trio leaf-name lookup graduation (after #27
aliaslookup, #28 fnparams, #31 fnret, #4a enum, #4b struct). Same
bundle precedent as #4a (which bundled wwstage enumlookup +
enumlookupmod + cstage scope_lookup_prefer sister fix under one
structural concern): four sister changes ship together.
- defent +dmod field; collectdefs captures d.module.
- wwstage deflookup two-pass walk — cosmetic (bool return is
invariant under head-pick vs same-module-first), kept for
structural symmetry with deflookuprhs.
- wwstage deflookuprhs two-pass walk — load-bearing for the
.ptr/.len field fold.
- cstage Sdef +mod field; sdef_collect captures d->module raw
(matches cgfn's raw cur_mod convention); new sdef_mod_match
helper handles NULL-safe strcmp; cstage Sdef walk #3 N_DOT
field-fold graduation (sister of wwstage deflookuprhs).
Two additional cstage Sdef walks (N_IDENT bare load + N_DOT mod-
qualified fallback) are DEFERRED. Both consume wwstage's
cgenexpr.ww:553 path which is independently broken (str-def bare/
qualified reference emits MOVQ symname(SB) where strlit-inline is
required); sentinel rows for those walks fail cs-vs-ws byte-id
regardless of the cstage prefer-pass behavior. Per rule 7 the
prefer-pass cannot ship without sentinels. Filed: task #11 (cstage
walk #2 also needs n->lhs->str as hint source rather than cur_mod,
matching #4a/#28/#31's *mod variant pattern) + task #12 (wwstage
str-def symbol-load fix that unblocks both deferrals).
735_def_modshadow pins the fix with 1 row: bare-leaf .len of MSG
in module alpha must fold against alpha's own def MSG (strlit
length 41) even with beta's same-leaf 27-char def MSG at the head
of c.defs / sdefs. Asserts the matching immediate inside the right
TEXT sym + bad_imm anti-check on both stages plus byte-id between
stages.
Callee N_IDENT word-copy loop was driven off slot-padded rt->size;
trailing narrow field (e.g. bool@32 in 33B/40B struct) widened to
MOVQ at the loop tail, diverging from wwstage's natural-size MOVB.
Class A cgen divergence. 9th unmask of session 5, corpus-coverage-
blind on the cstage side — no in-tree lib struct had a narrow
(bool/u8/i8/i16) trailing field until lib/strings.iterator landed
`reverse: bool` per Hare's ref/hare/strings/iter.ha:8.
Pre-fix: cstage cgreturn's sret arm (cgen.c) used `int sz =
(int)rt->size` for its chained `while (k+8<=sz)` MOVQ-MOVL-MOVW-MOVB
copy loop. rt->size is slot-padded (8-rounded for downstream
frame alloc), e.g. 40B for {i32, []u8, bool}. Loop emitted MOVQ
at offset 32 covering the 1-byte bool tail plus 7 bytes of
padding into the caller's sret slot — clobbering the next 7 bytes
of caller frame on read-side. Wwstage's mirror loop drives off
sretretsize → structnaturalsize → max(foff+fsz) = 33B, so it
correctly stops at offset 32 and emits MOVB.
Polarity catalog: cstage OVER-WIDE — slot-padded size driving the
field-copy width. Convergence cstage → wwstage's structnaturalsize
discipline (rule 10 inverse: leaner-correct side wins).
Fix: compute natural size locally in the N_IDENT/N_STRUCTLIT sret
arm via walk over `rt->fields` (max foff+fsz), mirroring wwstage's
`structnaturalsize` (cgenutil.ww:1377). Frame allocation and
`cg_sret_retsize` (used for the caller-side @sretscr slot)
intentionally keep using rt->size — caller scratch sizing is
separate from callee per-field copy width.
Surfaced by lib/strings commit-2 (#30) iterator pre-flight when
the Hare-faithful `reverse: bool` field tripped the cstage-only
mis-width on 993/995 byte-id (strconv → strings → wwdump_ww +
w6c_ww). lib/strings c2 was stashed to land cleanly after this fix.
Note (out-of-scope): the chained MOV loop has no MOVW arm in
either stage, so a trailing i16 emits 2× MOVB at consecutive
offsets. Worth a future cleanup; both stages agree today.
Tests:
- 730_sret_narrow_field pins narrow-MOV store + load width and
no-MOVQ@trailing-offset assertions for bool / u8 / i16 / i32
trailing fields in a 33B-natural struct. 4 rows × 5 sentinels
= 20 fixtures. cmp -s cstage vs wwstage byte-id per row.
- 930_sret_narrow_field_run runtime-pins bool true/false, u8
high-bit, i16 negative, i32 negative, mixed (bool+i32+i64 after
slice) — 6 scenarios × 2 stages = 12 rows.
104/104 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
cgen.c's two ≤24B / tagged-widen return scratch allocations called
mklabel(c, "retscr"), bumping labelseq once per function with a
struct or tagged return. Wwstage's mirror uses the fixed `@retscr`
name through `c.retscroff` SSoT (post-#14 b401cce) and never
touches labelseq for the scratch alloc. Result: cstage's labelseq
runs 1 ahead of wwstage in every fn with a struct/tagged return,
so every subsequent ct_N / ce_N / end_N branch label diverged by
the same offset.
Class A byte-id drift, previously latent. Filed STATUS-3 #15 —
promoted to bootstrap-blocking once lib/strings's time.add-chain
and nested-if shapes compounded the cumulative skew past the
993/995 byte-id threshold. The label name was never emitted (it's
a hidden local-table key); only the labelseq side-effect mattered.
Polarity catalog: cstage OVER — extra mklabel per fn. Convergence
cstage → wwstage's fixed-name SSoT per rule 10 (wwstage's pattern
is the cleanup target; STATUS-3 #14 already enforced single-slot
@retscr on both stages, this commit aligns the *name source* too).
Site 2 (struct/tagged @retscr in cgreturn) is the actively-tripping
site that reproduces in the in-tree corpus (lib/strings's time.add
chain). Site 1 is preventive symmetry per rule 10 — its sentinel
flip is masked by pre-existing pre-existing #20/#21 struct-widen
offset latents, documented inline.
Filed follow-up (NOT in scope here): #26 graduate the other
hidden-name mklabel sites (tagscr, tagbase, argscr, idxscr) to
@-prefix SSoT. Same family but per-site allocations, structurally
bigger; belongs with STATUS-4 task #1 variant-widen consolidation
refactor.
Tests:
- 725_nested_if_labels pins cstage vs wwstage cmp -s byte-id on
a canonical struct-return row mirroring lib/time.add shape
(multi-arm if + struct {sec,nsec: i64} return). This row
actually exercises mklabel site 2; the prior worker draft used
a scalar-widen path that bypassed both mklabel sites.
97/97 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
Class A compile-time fatal retirement — `return f()` from an sret
callee bailed both stages with "sret return-forwarding for >24B
struct not wired (task #23)" at every site, forcing every caller
into a `let r = f(); return r;` workaround that materialised an
intermediate >24B copy in outer's frame. Forwarding now elides the
copy: outer reloads its own @sretarg into RDI for the inner CALL
via `MOVQ @sretarg(BP), DI` (NOT `LEAQ <local>, DI`), inner writes
directly into outer's caller-prealloc dest, RAX (inner's returned
dest pointer per the sret discipline) is already outer's return
value.
Wires 2 sites × 2 stages (same triangle as #23): caller arg-shift
in cgcall/pushargsrev gains an RDI-source switch via
cg_sret_forward / c.sretforward; callee return-arm in cgreturn
replaces the fail-loud abort with cgexpr-into-cgcall + epilogue.
The @sretscr scratch slot is still pre-allocated on the forwarding
branch (unused) — eliding would need AST-walk awareness in
scanlocals; symmetric-allocate is the simpler path and keeps
byte-id with non-forwarding callers.
Latent surfaced and filed during probe (NOT in this commit's
scope): multi-sret-receive in a single fn diverges between stages
— cstage always allocates @sretscr on first sret CALL, wwstage
only when sretdestoff == 0. Bootstrap stays green because the
selfhost corpus has zero >1-sret-receive call sites.
Tests:
- 721_sret_struct_return gains 2 forwarding rows + a 4th asm-
presence sentinel: at the inner CALL site inside outer fn, the
RDI source must be `MOVQ -K(BP), DI` (reload of outer's saved
@sretarg) NOT `LEAQ -K(BP), DI` (a temporary local would write
inner's payload into outer's frame, not caller's dest).
- 925_sret_struct_return_run gains 3 forwarding rows: simple
quad forward, multi-arg inner (pair-by-value + scalar args
alongside the hidden RDI), and slice-payload (decoder
{ i64, []u8 } — the utf8 iterator shape, asserts ptr/len/cap
survive the @sretarg chain).
90/90 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).