Wwstage matchscrutt now mirrors cstage's typed-AST scrutinee-type
lookup for module-qualified mod.fn(...) callees, restoring per-arm
tag dispatch on cross-module shadowed-name 4-arm matches. Class A
runtime miscompile, silent across collectfnrets shadowing — was
the 8th unmask of session 5.
Pre-fix: wwstage's matchscrutt N_DOT branch (cgenutil.ww:2061)
called `fnretlookup(c, callee.str)` — name-only resolution.
collectfnrets prepends to c.fnrets, so when a caller fn (e.g.
lib/strings's `next`) shadows a callee fn-name (utf8's `next`),
the prepend chain has the caller's narrower tagged return at the
head. matchscrutt then resolved the scrutinee type to the WRONG
tagged shape, and variantindex lookups for arms past the
shadowing caller's variant count returned -1 → want=0 →
match-arm `CMPQ $0, AX` for arms 2 and 3 on a (rune | done |
more | invalid) probe. Effect: arms 2/3 silently unreachable
even when the runtime tag matched, falling through to default.
Cstage gets the scrutinee type via the checker-set callee type
on the N_DOT node, so picks the correct utf8.next return shape.
Polarity catalog: wwstage UNDER — fnretlookup missing module-
preferring discipline. **Third leaf in the same trio**: #27
(aliaslookupmod), #28 (fnparamslookupmod), #31 (fnretlookupmod).
Pattern is recurring; full graduation of all leaf-name lookups
to same-module-first is a candidate for STATUS-3 task #1
variant-widen consolidation refactor (deferred to next session
opener per rob).
Fix: new fnretlookupmod helper in cgen.ww (same-module-first
walk, fallback to existing first-match — cell-for-cell mirror
of fnparamslookupmod from #28). matchscrutt N_DOT branch
extracts `cmod` from callee.lhs.str and routes through the
helper. Other 13 fnretlookup callsites untouched per #28's
"fix only what has a real consumer" discipline. fnret.fmod
field + collectfnrets f.fmod assignment already landed in #28.
Surfaced by lib/strings commit-2 pre-flight: probe iter+next
shape calls utf8.next; the probe's own `fn next` shadows
utf8.next at the c.fnrets head. Bootstrap-stable because no
selfhost-corpus path shadows a fn name across modules with a
wider tagged return on the shadowed side; lib/strings.iter
pulling utf8.next under wwstage was the first exerciser.
Filed follow-up (NOT in scope here): #32 wwstage runtime stomp
on utf8.next via *iterator caller — separate Class A surfaced
by 929 direct utf8.next regression row design. #31's fix is
correct in isolation; #32 blocks lib/strings commit 2 (#30).
Tests:
- 728_match_4arm_cross_module pins distinct CMPQ $K, AX tags
in TEXT b.next via bitmap covering [0..arms), robust to
arm ordering. Three cross-module shadowed-name shapes × cmp
-s byte-id. Sentinel-flip-verified: revert fnretlookupmod
route → 3/6 wwstage fixtures fail "arm K repeats tag $0
(collapse)".
- 929_match_4arm_cross_module_run runtime-pins 6 rows × 2
stages per-arm exit-code shape: 3/4/5/6-arm boundary,
mixed (i32|str|rune|u8), reverse arm-order in match source.
Confirms bug follows fnretlookup-resolved type, not match
source order.
102/102 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
Wwstage cgcall now mirrors cstage's typed-AST callee-params
lookup for module-qualified mod.fn(...) calls, restoring tagged-
union widening on cross-module slice args. Class A runtime
miscompile — masked from 995_self_rebuild because wwstage tools
don't call bytes.index directly; surfaced by lib/strings landing
dragging utf8 + bytes into the wwstage-tool dep chain via
strings.byteindex's `bytes.X(toutf8(...), n)` call sites.
Pre-fix: wwstage's cgcall (cgenexpr.ww) looked up calleeparams
only when callee.kind == N_IDENT. For N_DOT callees (the
module-qualified mod.fn() form), calleeparams stayed nil →
pushargsrev's widening detection gated on param != nil never
fired → wwstage fell through to the N_IDENT-slice fast path
pushing only 3 slot words (cap, len, ptr) WITHOUT the variant
tag. Receiving fn's `match (needle)` then dispatched on
(needle.ptr in CX) instead of needle.tag, with R8/R9 carrying
.len/.cap instead of .ptr/.len. Wrong arm + wrong payload.
Cstage handles N_DOT natively via the checker-set type on
n->lhs->type (cmd/w6c/cgen.c:4156-4165), so cg_widen_tagged_push
slice path pushes 4 words including tag.
Polarity catalog: wwstage UNDER — calleeparams lookup missing
N_DOT dispatch arm. Sister to #19 (N_TSLICE variantindex arm),
#21 (N_CALL pushargsrev arm), #24 (N_CALL nodeisslice arm), #27
(aliaslookup same-mod-first). The pattern: wwstage dispatchers
keep missing arms cstage has natively via typed-AST resolution.
Convergence wwstage → cstage (rule 10's spirit overrides letter
when correctness is at stake — Path 2 of aligning cstage DOWN
would create a runtime miscompile in both stages).
Fix: cgcall N_DOT branch pulls module from callee.lhs.str and
function name from callee.str, calls new fnparamslookupmod
helper. Helper does same-module-first walk then existing
first-match fallback (mirrors #27's aliaslookup fix shape). New
fnret.fmod field carries module identity; collectfnrets sets
f.fmod = d.module at registration. Module-qualified pkg.fn path
unchanged.
Tests:
- 727_modcall_widen_slice pins MOVQ $1 + PUSHQ AX (tag-synth)
before the receiving fn's CALL on canonical mod.fn(slice, ...)
shape with the callee param widened to a tagged union. Three
assertions per row: cstage tag-synth presence, wwstage
tag-synth presence, cstage↔wwstage cmp -s byte-id. Sentinel-
flip-verified: comment out fnparamslookupmod call →
wwstage tag-synth absent + cmp diverges.
99/99 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
wwstage UNDER — aliaslookup's leaf-only first-match walk let a
cross-module leaf collision (`type invalid = !i32;` ahead of
`type invalid = !void;` in c.aliases) shadow module M's own
alias. Silent-correct-by-zero-init: the let-decl prologue zeroed
the slot 8B-wide (typeis8byteprimitive's void-aliased path,
post-#22), so MOVSXD on the misresolved !i32 produced the right
value while diverging from cstage's MOVQ — bootstrap byte-id
held until any caller bumped the alias-chain ordering. Mirrors
cstage scope_lookup_prefer (cmd/wcc/check.c:65); module-
qualified pkg.alias path unchanged.
Polarity catalog: wwstage UNDER — aliaslookup missing module-
preferring scope discipline. Convergence wwstage → cstage's
resolver pattern (rule 10; cstage already correct via
scope_lookup_prefer). Two-pass walk: same-module first, then
existing first-match fallback. Sea-of-stars shape preserved.
Surfaced by lib/strings landing: utf8's `type invalid = !void;`
and strconv's `type invalid = !i32;` registered in the same flat
c.aliases under one combined.ww, with strconv's later-registered
entry sitting at the head of the chain. utf8.next/decode's
`return e;` (e: invalid) packed via MOVSXD instead of MOVQ. Four
sites in main.s, contributing to 993/995 byte-id divergence in
the wwstage rebuild path.
Tests:
- 726_alias_leaf_collision row 1 pins MOVQ post-zero-init on
both stages and cstage↔wwstage cmp -s byte-id for the
`(invalid:!void via beta)` shape with `alpha.invalid = !i32`
seeded ahead in c.aliases. Sentinel-flip-verified: revert →
wwstage emits MOVSXD post-zero-init + cmp diverges.
- Row 2 (i32_local_read_keeps_movsxd) gates against future
symptom-fix attempts: legitimate `let i: i32; return (i:i64);`
must still emit MOVSXD on both stages (>=2 narrow signed loads
in the promote fn TEXT). Independent of the aliaslookup fix.
98/98 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
Wwstage's cglet skipped MOVQ $0 for sz=8 slots that cstage
zero-inits unconditionally — !void error types (utf8.invalid)
and void-alias variant tags (utf8.done / utf8.more) drifted
byte-id post-utf8 + lib/strings; promotes STATUS-3 #22 from
latent to bootstrap-blocking. Cstage emits MOVQ $0, -K(BP) in
the prologue for any sz=8 let-decl slot via the natural
type-fallthrough; wwstage's `typeis8byteprimitive` helper
returned false on N_TBANG and on N_TNAME pointing to an alias
that resolves to void, so the gate never fired and the slot
stayed uninitialised.
Polarity catalog: wwstage UNDER — `typeis8byteprimitive`
classifier too narrow at N_TBANG and void-alias N_TNAME.
Convergence wwstage → cstage's natural sz=8 fallthrough (rule
10). N_TBANG arm recurses on inner type (cmd/wcc/check.c:290
resolve_type copies T's kind, only sets iserror — so !T is
8B iff T is 8B); void-alias N_TNAME resolves through alias-
recursion the same way.
Tests:
- 724_letdecl_zeroinit pins MOVQ $0, -K(BP) presence between
function prologue and body on canonical !void and void-
alias rows, plus cmp -s byte-id between stages per row.
Filed follow-up (NOT in scope here): #25 wwstage 8B struct
without rhs still under-emits (structlookup != nil short-
circuits the classifier). Same family as STATUS-4 #36 primsize
composite-aware sizing. No in-tree consumer.
96/96 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
Wwstage call-arg-emit recognized slice args only when source was
IDENT/SLICE/CAST/DOT. For N_CALL returning []T the natural-push
fallthrough emitted one PUSHQ AX (lost .len/.cap) and cgcall's
pop-count under-drained by 2 words — corrupting R8/R9 and every
subsequent arg. Class A runtime miscompile with stack misalignment
and 3-POPs-of-garbage at the receiving call. Sister to #21
(tagged-CALL arg) but for plain []u8 slice, not tagged-variant —
wwstage's pushargsrev grew the tagged-CALL arm at #21 and never
grew the plain-composite arm.
Surfaced by lib/strings landing's `bytes.X(toutf8(in), p)` call
sites: 995_self_rebuild's wwstage rebuild tripped on byte-id
divergence at w6c_ww + wwdump_ww emit. 967_bytes_run was green
because `ww run` exercises the cstage path. Corpus-coverage-blind
on the wwstage side until lib/strings pulled the chain through
wwstage compilation.
Fix is minimal: `nodeisslice` (selfhost/cmd/wcc/cgenutil.ww) gains
an N_CALL arm structurally identical to the existing N_CALL arm in
`nodeisstr` (only swap: isslicetype for isstrtype). The downstream
natural-push slice path (PUSHQ CX/BX/AX, extra=2 pop-count) was
already correct — it just needed the N_CALL-of-slice-return shape
to be recognized as a slice. pushargsrev and cgcall untouched.
Polarity catalog: wwstage UNDER — missing N_CALL arm in slice
shape recognition. Convergence wwstage → cstage per rule 10
(cstage reads typed-AST `type_isslice` natively).
Tests:
- 723_composite_call_arg pins the 3-PUSH order (CX, BX, AX)
between `CALL view` and next CALL on canonical `f(g())` shape,
plus cstage vs wwstage cmp -s byte-id.
- 927_composite_call_arg_run runtime-pins 7 rows × 2 stages =
14 fixtures: canonical, slice-CALL + let-slice (hasprefix
shape), two composite-CALL args (arg-shift collision),
middle-argpos, nested composite-in-composite, slice + scalar
pop-count mix, tagged-CALL regression alongside (confirms
#21 still holds).
95/95 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
Class A wwstage cgen miscompile, silent until wwstage path engaged.
Pre-fix wwstage's name-keyed flatvariantidx returned -1 for `[]T`
variants (pat.str empty on N_TSLICE), so cgmatch and
cgtagvariantidx collapsed every `(scalar | []T)` arm to tag 0.
Internally consistent within wwstage; cstage's structural
`type_eq` (cmd/w6c/cgen.c:466 cg_tag_for_variant) matched
correctly. Bootstrap stayed green because no selfhost-corpus path
exercises `(scalar | []T)` until lib/bytes / lib/strings landing
pulls bytes.index through wwstage compilation — 967_bytes_run
uses `ww run` (cstage only), so the wwstage path was never
exercised.
Polarity catalog entry: wwstage UNDER (missing N_TSLICE dispatch
arm in variantindex lookup), not REVERSE — worker's deeper read
corrected rob's initial diagnosis. cstage's structural type-eq is
the leaner-correct side; wwstage converges to it per rule 10.
Fix: new `flatslicevariantidx` helper in cgenutil.ww keyed on
N_TSLICE shape walking pat.lhs against vt.lhs alongside the existing
name-keyed flatvariantidx; extend `taggedvariantindex` shape-fallback
with a `wantslice == ivisslice` axis alongside the existing str
axis; route N_TSLICE in cgenexpr.ww's cgtagvariantidx (is/as)
and cgmatch (case) through the helper. No edits to cgenmatch's
dispatch codegen (CMPQ/JNE/spill) — that's symptom, the bug is
in the variantindex lookup.
Surfaced the 7th corpus-coverage-blind unmask of session 5 (sister
shape to STATUS-4 #11 / #14 / #21 wwstage UNDER family). Latent
within lib/bytes (a6abac2) since landing today; 967_bytes_run's
cstage-only `ww run` driver kept it dormant.
Tests:
- 722_match_slice_variant pins cmp -s byte-id between stages
for the canonical (u8|[]u8), reverse-order ([]u8|u8), and
three-arm (u8|[]u8|str) shapes.
- 926_match_slice_variant_run runtime-pins 7 rows × 2 stages
(cstage + wwstage drivers): canonical, reverse-order, and
other scalar-vs-slice-of-same-primitive matrices (i8|[]i8,
i32|[]i32, u64|[]u64, rune|[]rune), three-arm with str.
Verifies both arms reachable and payload survives.
Filed follow-ups (latent, NOT in this commit's scope):
- flatslicevariantidx falls back to first slice slot when no
element-name matches; `([]u8 | []i32)` would mis-route. No
in-tree consumer.
- 926 missing nested ((u8|[]u8) | i32) row per rob's spec.
- 3-arm 32B tagged sequential-push payload corruption (both
stages, asm byte-id passes, only 9xx runtime catches).
- Chained inline pick() over 32B 3-arm slot (both stages,
bind-to-let workaround documented at 926 row).
93/93 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
Pre-#33 workaround widened kstat.mode to a typed alias to dodge
an MOVL emit issue. With #33 (session 3) landed, plain u32 works
and matches both the kernel SYS_newfstatat struct layout (st_mode
is unsigned int) and Hare's sys/+linux/types.ha:120 st.mode width.
The cast-to-mode at fillfilestat is retained (kstat.mode is a u32
holding mode-typed bits, and the cast carries that intent).
Keeps the kstat surface internally consistent with its other raw-
primitive fields (uid: u32, gid: u32, ino: u64, …). A future
Hare-fidelity pass can graduate kstat to the t-suffixed aliases
({uid,gid,mode,…}_t) but that's a separate consolidation.
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).
Class B shared miscompile pre-fix: cstage skipped the CALL emit at the
receive site (frame collapsed, exit 11); wwstage emitted CALL but
truncated 32B return to AX only (slice payload garbage, segfault on
g.b[0]). Both stages now lower plain TY_STRUCT > 24B through the SysV
sret discipline: caller pre-allocates dest, passes &dest in RDI as a
hidden first-arg (user args shift to SI/DX/CX/R8/R9/+stack), callee
saves RDI to @sretarg at the prologue and writes through it, returns
RDI in RAX. Surfaced by lib/encoding/utf8 pre-flight when the
Hoehrmann decoder (32B) hit 698_cgreturn_struct.c's OUT-OF-SCOPE
marker.
Scope: plain TY_STRUCT > 24B only — tagged unions, tuples, str, slice
keep their existing register-return ABIs. `return f()` forwarding
from a sret callee is fail-loud-not-wired (compile-time error in
both stages, follow-up filed); the workaround `let r = f(); return
r;` is wired and byte-identical. Discard-context calls (`f();` of an
sret-returning function) share a per-fn single-slot @sretscr;
consecutive discards reuse the same slot.
698_cgreturn_struct.c's OUT-OF-SCOPE marker retired in the same
commit; three positive rows (32B quad, 32B decoder, 40B five) now
assert the sret discipline across both stages via byte-id diff.
Tests:
- 721_sret_struct_return pins three asm-presence sentinels per
row: (a) LEAQ -K(BP), DI immediately before CALL at the receive
site, (b) MOVQ -K(BP), AX before RET in the callee (sret return-
the-pointer), (c) negative-assert no MOVQ AX, -K(BP) capture for
return type >8B. Three rows × both stages × cmp -s byte-id.
- 925_sret_struct_return_run runtime-pins 7 rows × 2 stages
including the collision row (25B+ struct BOTH returned AND passed
by-value as arg — catches arg-shift, sister site to #11), nested
struct payload, slice payload, reassign-receive, N_IDENT return
rhs.
89/89 ok. 995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
Wwstage call-arg-emit recognized tagged args only when the source
was an IDENT (already-materialized var). For N_CALL returning a
tagged-union, the natural-push path mis-routed: AX (tag) pushed
twice, AX clobbered with widentag(=0) between pushes, DX (payload)
dropped entirely. After POP, DI ← 0, SI ← tag — both reversed and
the payload word lost. Class A runtime miscompile, masked by zero
in-tree call sites of the shape until lib/encoding/utf8's iterator
API surfaced it via pre-flight A probe.
Fix aligns wwstage DOWN to cstage (rule 10). cgenutil.ww:pushargsrev
aistagged guard now fires for N_CALL whose callee returns a tagged
whose slot matches the param's tagged slot (mirrors cmd/w6c/cgen.c:
4216-4221's type_eq guard), and the natural-push fallthrough adds a
tagged-CALL arm pushing R8/CX/DX/AX high→low by slot size (mirrors
cmd/w6c/cgen.c:4373-4387). cgenexpr.ww:cgcall's per-arg pop-count
picks up the same taggedcallslot helper so the next arg's POPQ
doesn't land on residual tag/payload words.
Sister-family to #11/#14 in the variant-widen ABI chain — call-site/
caller-side surface, distinct from callee-side #11 (param decompose)
and scratch-side #14 (return slot). Fifth corpus-coverage-blind
unmask this session (catalog: i64 div/mod CQO #16; wwstage IDENT-
local /= no-op #16-B2; cstage signed-DATA module-scope #19; wwstage
silent-zero arrays #19 mirror; #21 call-arg DX drop).
Test: 720_tagged_call_arg asm-presence row (PUSHQ DX appears
between CALL and next CALL, before PUSHQ AX) + 924_tagged_call_arg_
run 9xx semantic row (5 rows: 4-variant CALL-source, 4-variant
IDENT-source regression guard, 2-variant ptr/err, multi-arg tagged
+ scalar). Bootstrap byte-id (ww2 == ww3 == ww4) holds.
emit_lets / emitletdataw's scalar-8B and array arms only matched bare
N_INTLIT / N_RUNELIT / N_TRUE / N_FALSE / N_NIL on the let rhs. `let
x: i8 = -1i8;` arrives as N_UN(TK_MINUS, N_INTLIT(1)) — none of those
— so cstage's scalar arm hit `else continue;` and dropped the DATAW
row entirely; the array arm bailed at the first non-foldable element
and the skip-array-with-non-NIL-rhs fall-through dropped the whole
row. Wwstage's mirror arms silently emitted zero bytes for negative
literals in both shapes.
Severity split — cstage symptom is no DATA emit, the linker fails
loudly at build time. Wwstage symptom is silent-zero element
substitution for negative array values: compiles, runs, returns
wrong answers. Corpus-coverage-blind on the wwstage side, only
surfaces when a consumer reads the wrong value. Single N_UN-fold
helper application retires both symptoms across both stages. Fourth
corpus-coverage-blind unmask this session (catalog: i64 div/mod CQO
#16, IDENT-local /= no-op, #21 call-arg DX drop, now #19 wwstage
silent-zero).
Route all four sites through fold_int_literal / foldintliteral, the
same helper #24 used on the def-emit side (which already covered
N_UN over the leaf set). The wwstage array arm also picks up an
N_CAST peel and drops a dead non-`...` N_FIELD branch (the parser
never emits non-`...` N_FIELD inside an N_ARRLIT — only as the `...`
repeat marker). Same-path sibling cleanup; rule-11 justified.
Tests:
- 719_signed_data_emit asserts DATAW <sym>(SB),"<bytes>" lines are
present in both stages' .s for the {i8, i16, i32, i64} × {scalar,
1D array} matrix, plus cmp -s byte-id between stages per row.
Corpus-coverage-blind sentinel per rob's STATUS-3 note.
- 923_signed_data_emit_run runtime-pins the same matrix plus a
TK_TILDE row through cstage and wwstage drivers.
995_self_rebuild stays green (ww2==ww3==ww4 byte-id).
Removes the local os.timespec (sec, nsec) struct in favour of
time.instant from lib/time. lib/os now `use time;`. filestat's
atime/mtime/ctime change type with byte-identical layout
(i64+i64=16B both sides), so .sec / .nsec accessors at all caller
sites work unchanged.
Rule 12: simple data + mirror Hare. Two same-layout types — one
Hare-canonical, one not — is exactly the structural divergence the
rule forbids. Single-source-of-truth; no transitional alias.
Citations: ref/hare/fs/types.ha:141 (Hare's fs::filestat carries
time::instant), ref/hare/time/instant.ha:9 (canonical layout).
Caller impact (sole reader): lib/os/stattest.ww (.sec / .nsec
unchanged; one comment line refreshed). examples/cmatrix migrated
already in 7e9bede. No selfhost/cmd/* reads mtime/atime/ctime.
Test wiring: lib/os/os.ww removed from 900_stdlib.c's standalone-
w6c-codegen list (cross-module type ref now needs the driver's
module concatenation, same reason lib/bufio and lib/fmt graduated
off earlier). Coverage stays at 976_stat_run via stattest.ww.
Makefile dep edges for the five wwstage targets gain
lib/time/time.ww so changes to it trigger wwstage rebuild.
lib/time is now in the toolchain transitive chain via lib/os. No
selfhost cmd calls time.add/time.diff today; bootstrap is safe.
Latent risk: any future selfhost edit adding time.add/time.diff
would surface task #15 (nested-if label-counter skew in lib/time/
add) as a bootstrap regression. File a fix-#15 before such an edit.
Bootstrap byte-id: ww2 == ww3 == ww4 for all five wwstage tools.
B1 (63332fe) landed CQO in both stages' assemblers and switched the
binary `/` and `%` paths to it. The compound-assign sisters (`/=`,
`%=`) were six explicit workarounds across both stages, all calling
out either "fallback for TK_SLASHEQ" or just falling through with no
case at all. With CQO available, every site mechanically ports to the
same "park rhs in CX, slot value into AX, CQO/IDIVQ CX, ferry result
back" sequence.
wwstage cgenexpr.ww:5381-5403 silently no-op'd IDENT-local signed
compound div/mod — `x /= y` and `x %= y` produced no IDIV emit at
all, just a load-bearing `MOVQ BX, off(BP)` that wrote the freshly
loaded slot value back unchanged. Bootstrap byte-id passed because
no selfhost-corpus path exercises signed compound. Latent miscompile
retired alongside the workaround revert.
cstage cgen.c:3735 (top-level-let global compound) was NOT in the
initial five-site bundle and surfaced via worker probing the
wwstage:5147 fix — `let gs: i32 = 100; gs /= 7;` returned 7 (divisor)
on cstage but 14 (correct quotient) on wwstage. Rule 10 caught the
would-be Class A divergence; the sixth site bundles in.
Six sites, one family:
cmd/w6c/cgen.c:3549 deref-compound `*p OP= v`
cmd/w6c/cgen.c:3735 top-level-let `gs OP= v`
cmd/w6c/cgen.c:3765 IDENT-local `x OP= v`
selfhost/cmd/wcc/cgenexpr.ww:3338 deref-compound
selfhost/cmd/wcc/cgenexpr.ww:5147 top-level-let
selfhost/cmd/wcc/cgenexpr.ww:5381 IDENT-local (silent-no-op)
test/wcc/978_intdiv_signed.c adds 7 compound rows × 2 drivers = 14
fixtures (now 68/68): IDENT-local /= /=- /=u, deref *p /= *p %= *p
/=u, with negative-dividend, negative-divisor, and unsigned-high-
bit-set coverage. Top-level-let compound coverage is deferred per
task #18 — single-file inline drivers hit a pre-existing linker
`undefined reference to '<file>.gs'` for LEAQ name(SB) targets.
cstage:3735 and wwstage:5147 are code-review-verified for rule-10
symmetry until #18 lands.
Grep-sweep (`if (n < 0) { neg = true; n = -n; }`) returned two sites
in lib/fmt/fmt.ww i64dec and lib/strconv/strconv.ww — both mirror
ref/hare/strconv/itos.ha's pre-negate idiom for INT64_MIN safety.
Per the Hare-faithful filter, both stay.
Shared miscompile in both stages — not a divergence. Bootstrap byte-id
passed throughout because both stages emitted the same wrong asm. Both
the C cgen (cmd/w6c/cgen.c TK_SLASH/TK_PERCENT) and the ww cgen
(selfhost/cmd/wcc/cgenexpr.ww) prepped IDIVQ with `MOVQ $0, DX`, which
is the unsigned 128-bit dividend shape. For a negative RAX, the CPU
then divides 2^64 + (-RAX) by the divisor — unsigned wraparound, not
signed division. Surfaced via lib/time/add() needing the verbatim Hare
signed-%-normalisation in ref/hare/time/arithm.ha.
Fix: emit CQO (sign-extend RAX into RDX:RAX, REX.W 99) on the signed
arm; keep MOVQ $0, DX on the unsigned arm where the DIVQ-vs-IDIVQ
dispatch was already correct. Since both stages always emit 64-bit
IDIVQ regardless of source width, a single CQO suffices for
i64/i32/i16/i8 — the dividend already lives in RAX sign-extended. No
CDQ/CWTL/CBTW needed.
Symmetric stages (rule 10): both stages were broken identically; both
get the same surgical fix. Adds A_CQO to each assembler's opcode set:
cstage in cmd/w6c/6.out.h + cmd/w6c/txt.c + cmd/w6a/{parse,asm}.c;
wwstage in selfhost/cmd/w6a/{types,parse,asm}.ww.
Class B (shared miscompile) — new in the session's polarity catalog.
Bootstrap byte-id is useless for catching it; semantic 9xx runtime
tests are the right shape. test/wcc/978_intdiv_signed.c covers 27 rows
× 2 drivers = 54 fixtures across {i8,i16,i32,i64,u8,u16,u32,u64} ×
{/, %} with width-boundary minima (INT8_MIN, INT16_MIN, INT32_MIN,
INT64_MIN/2) and high-bit-set unsigned anchors. INT64_MIN is spelled
(-INT64_MAX) - 1 per task #17 (wwstage NEGQ-over-imm drops digits on
-9223372036854775808i64); that literal-cgen bug is unrelated to this
fix.
Two known compound-assign workarounds at cmd/w6c/cgen.c:3765
(TK_SLASHEQ IDENT-local) and :3549 (TK_SLASHEQ/TK_PERCENTEQ
deref-compound) remain in tree; both depend on the assembler having
CQO, so they revert in a follow-up commit citing this one.
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)
Path-shaped entrypoints now take str: open, tryopen, access, remove,
mkdir, rmdir, mkdirs, stat, lstat, exists, execve (path arg only).
Each cites its Hare source (ref/hare/os/*.ha, ref/hare/sys/+linux/
*.ha).
New internal kpath(str) *u8 copies into module-level pathbuf: [4096]u8
and NUL-terminates; mirrors ref/hare/sys/+linux/syscalls.ha:25,53.
Non-reentrant — graduates with thread story. mkdirs flattens to one
kpath at entry then walks pathbuf invoking raw SYS_mkdir to avoid
nested kpath clobber.
One Hare divergence at kpath: ships *u8 with nil ENAMETOOLONG sentinel
instead of (*const u8 | errno). Reason: wwstage over-allocates
1-word-payload tagged returns to 24B (cstage emits 16B); filed as
follow-up. Repro at .ai/probe_tagged_return_pointer_payload.ww;
graduates when fix lands.
Each selfhost cmd grew a private pathstr(*u8) str (cstrlen + bs) for
remaining *u8 path sites; w6l shares via obj.ww. Probe 7 in smoke
updated.
Tests 975/976/981 cover migrated entrypoints; 976 extended with two
ENAMETOOLONG rows (-36 for stat, false for exists).
Initializing a tagged-union variant slot with a runtime f64 source
(let, cast, fn call, unary, struct field, etc.) stored the i64 bit
pattern in the payload, not the float bit pattern. cgexpr leaves f64
in X0; the existing scalar-fallback MOVQ-from-AX wrote whatever was
last in AX (typically pre-conversion integer or stale residue).
Worker-fmtfloat surfaced this during #17 pre-flight (probe at
.ai/probe_f64_union_widen.ww). Blocks #17 fmt.float dispatch arm.
TK_FLOAT literals were coincidentally correct because the lowering
loads bits into AX before passing through X0 — the literal_1_0 test
row pins that as the principled MOVSD path now.
cstage cg_widen_tagged_store: add fld_isfloat arm between the slice
and scalar fallbacks. Emit MOVSD (f64) / MOVSS (f32) from X0 to the
payload offset, then the tag MOVQ. Mirrors existing str/slice/
structlit field-flow dispatchers.
Wwstage cgwidentaggedstorebp: mirror via exprfloatkind. Resolves a
secondary gap by looking up the variant tag directly via
flatvariantidx(c, dt, "f64"/"f32") — rhstargetname has no N_FLOATLIT
/ N_CALL / N_DOT branch and would fall through to str-fallback
returning tag 0.
No in-tree consumer triggered this pre-fix (no f64 in any tagged
union yet) — hence latent silence. arr[i]= and append() have the
same class gap but no in-tree exerciser today; same shape if/when
[N]f64 / []f64 land.
Test 715 (tagged_widen_f64): 7 rows × 2 stages = 14 fixtures with
bit-pinning via *u8 punning. literal_1_0 (regression lock-in),
cast_1_f64, call_makeone, unary_neg_f64, ident_f64, field_f64
(rob's extra row), i64_rhs_still_integer (negative control).
Diagnosable 0/1/2 return codes distinguish pass / wrong-tag /
wrong-payload.
ww2 == ww3 == ww4 byte-identical post-fix.
wwstage's taggedvariantindex returned -1 (caller maps to 0) for
N_IDENT returns of an aliased mixed-variant union. Cstage returned
the correct variant index. Cross-stage divergence — root cause of
worker-fmtparser's "reads bool-true as false" symptom in the #18
repro chain. Worker-18 dodged it by dropping 707's asm byte-id
loop; #20 re-enables it.
Unwrap at entry: resolvetagged peels N_TNAME alias chains down to
the underlying N_TTAGGED before the variant-index walk. Direct-
tagged callers are unchanged (resolvetype is a no-op on non-N_TNAME).
Mirrors nodeisstr's shape — same class of wwstage-no-typed-AST gap
tracked by #11.
Test 707 grows from 6 → 9 rows; new rows pin tag=0/1/2 (i64/str/
bool) explicitly so a future variant-reorder can't hide behind a
coincidentally-correct tag=0. Asm byte-identity loop re-enabled
(disabled by #18); now exercises both #18 (ABI words) and #20
(variant-index) fixes — rows 2/3/6 also probe str/bool divergence.
995_self_rebuild green confirms wwstage source itself has no
latent aliased-tagged-return that would have surfaced as a self-
divergence.
Closes STATUS latent #1: @tagscr shared 24B reservation across the
four tagged-scratch sites (cgreturn, pushargsrev, cgindex
tagged-elem, pointer-rooted struct-field tagged write). Any fn that
needed >24B (e.g. slice-in-tagged-field 32B) silently overflowed
into the neighbor frame slot. Surfaced concretely as getopttest's
errortable wwstage exit 16 after #37 fixed the upstream gaps.
c.tagscrsz: i32 on the cgen struct is the single source of truth.
tagscrbump(c, need) in scanlocals raises the max across all 4
reservation sites and returns the frame delta. All emit sites
(cgreturn / pushargsrev / cgindex / cgwidentaggedstore pointer-
rooted) read c.tagscrsz instead of hardcoded 24. Mirrors the existing
cgwidentaggedstore precedent; @tagbase keeps its 8B scanseenmark
dedup (always 8B, correct).
Unmasked latent bug (now fixed): scanlocals's pointer-rooted struct-
field tagged-write detection uses localfindnode(c, base.str) to
resolve the *struct base. For `fn fill(h: *holder)`, h's scan-time
stub from scanseenmark had tnode=nil, so the @tagscr reservation
never fired. Pre-#38 the hardcoded 24B masked this; #38's correctly-
sized slot exposed it. cgfn's param scan loop now sets
c.locals.tnode = scanp.lhs after scanseenmark so localfindnode
resolves param types at scan time.
Test 714 (tagged_return_scratch): 4 rows × 2 stages = 8 fixtures.
Direct adjacency repro; match-arm field-by-field read; **mixed-
sizes-one-fn** (16B pushargsrev widen + 32B cgreturn widen in the
same body — pins the lockstep invariant that a sibling site can't
undersize the shared slot); call-site struct-payload widen. Row 3
specifically would regress if a future refactor ever forgets to
route an emit site through c.tagscrsz.
982 getopt_run green through both stages (was the original surface);
995 self_rebuild byte-id holds.
The original #37 symptom (worker-34's `..findflag` mangle) cannot
reproduce on master — was a runtime miscompile misattributed to a
link-time issue. Investigation surfaced three real wwstage cgen
gaps in the N_INDEX-through-struct-field family, sister bugs to
#34 (N_INDEX N_IDENT-base) and #36 (primsize-default-to-8).
1. `indexbaseesz` slice-element stride defaulted to 8 for named-
struct elements. `&opts.ptr[i]` for `opts: *[]option` computed
MOVQ $8 instead of $24. Fix: route slice case through
`elemsizeofc(c, innert)`; ptr-to-named-struct via structlookup.
2. `cgun TK_AMP N_INDEX` ignored N_DOT base. `&p.ptr[i]` left
esz=8 because only N_IDENT base was handled. Mirror cgindex's
existing N_DOT arm.
3. `nodeisstr` N_INDEX arm only walked N_IDENT bases. `cmd.argsptr[i]`
for `argsptr: *str` returned false; pushargsrev dropped the
.len half at call sites. Add N_DOT-base arm that walks the
struct field's pointee.
Test 713 (struct_field_index): 3 rows × 2 stages = 6 fixtures, one
per fix shape. Runtime-only; bootstrap byte-id (995_self_rebuild)
covers cross-stage drift.
Residual: getopttest's errortable still fails through wwstage with
a slice-of-str-via-&arr[expr] miscompile. Filed as task #38.
wwstage's nodeisstr (cgenutil) didn't recognize N_INDEX-of-[N]str.
cgindex emitted only the ptr-half MOVQ when the result was used as
a str arg (call, .len access, str streq), so the .len half read
stack residue. Surfaced by worker-21 during #21 dev — pre-#21
slotsize=24B masked the read-side defect; post-#21 (16B stride)
exposed it. cstage's typed-AST node_isstr handles this naturally;
wwstage's untyped pattern walks the base ident's tnode shape.
Added N_INDEX arm to nodeisstr: walk the indexed base's tnode
through N_TARRAY / N_TSLICE / N_TPTR.lhs, return isstrtype on the
element. Mirrors cgindex's own base-type walk byte-for-byte in
shape so the two now agree on load-shape decisions.
Not covered (separate bugs, separately filed):
- N_UN(TK_STAR) of *str — cgun itself never loads .len into BX.
- tuple `.1` of str — N_TTUPLE path has its own load shape.
- alias-typed base (`type a = [N]str`) — N_TNAME isn't peeled; cgindex
doesn't peel it either, so agreement holds. Outside #34 scope.
Test 711: 3 new rows — barelet_index_call_arg (streq direct arg),
nested_call_index_arg (f(g(argv[i])) — nested-call recursion),
barelet_index_len_arg (sister regression-pin for cgindex element
stride in bare-let context; pins a different code path that was
already correct post-#21).
The pre-existing wwstage `..findflag(SB)` symbol-mangling bug in
getopttest wwstage build is filed as task #37, not in this commit's
scope.
cmd/wcc/check.c silently accepted `let a; let a;` in the same block
and similar redecls. Pre-#27 the localoff dedup masked it; post-#27
last-write-wins via head-first localfind. Surfaced by worker-27
during the #27 review.
Cstage: 5 guard sites (check_scope_define-NULL → err) covering
N_LET block-bind, N_MLET tuple binders (incl. same-tuple
`let (a,a)`), N_FORRANGE tuple binders, top-level let, fn param.
Voice: "<kind> '<name>' redeclared in same scope" for inner;
"duplicate let %s" for top-let, matching the existing
"duplicate <kind>" idiom at 1812/1851/1872.
Wwstage: TODO(#11) comments at the 4 mirror sites (installdecl,
N_FORRANGE, N_LET, installparams). Full enforcement waits on the
checkfile pass per rob.
**Unmasked by #32 (worth flagging):** selfhost/cmd/wcc/cgenexpr.ww
cgcall had `let callee: *node = n.lhs;` twice at fn-body scope
(copy-paste, identical value). Pre-fix silent-redecl absorbed it;
post-fix the new guard rejects. Removed the second decl — outer
`callee` stays visible across the intermediate block.
Test 712 (redecl): 10 rows (6 neg + 4 pos), cstage-only per rob.
Negative rows cover all 5 guard sites + same-tuple-dup. Positive
rows pin the legal counter-shapes (cross-block, name-only bucket,
forrange body, mcase-per-arm).
Test 300 row 34 ("shadowing in inner scope; same scope flagged")
was incorrectly asserting the bug; flipped to expect "redeclared"
and added a sibling row pinning cross-block shadow stays ok. Test
709's `same_block_redecl_pin` canary (explicitly documented as
flipping under #32) removed; pointer to 712 left in its place.
[N]str array literals wrote only the .ptr half of each element.
cstage used esz=16 from `lu->sub->size` and a single per-element
MOVQ → .len trailed uninitialized stack residue. Wwstage was worse:
primsize("str")=0 fell through to esz=8, so element i+1's ptr-MOVQ
clobbered element i's .len slot, scrambling everything.
Worker-18 sidestepped during #18 by rewriting array primer rows to
[N]i64.
cstage cgen.c N_ARRLIT TY_STR branch: emit AX → base+i*16 then
BX → base+i*16+8. Repeat-`...` path mirrored. type_isstr handles
TY_UNTYPED_STR + TY_NAMED-aliased-str.
Wwstage cgenstmt.ww: isstrel flag conditionally drives the two-MOVQ
store in both the per-element walk and the repeat fill. The dispatch
loop was refactored to unify FIELD/ellipsis branches via isellip,
cleaning up the duplicated arms.
Wwstage cgenutil.ww slotsize/letslotsize: TNAME-"str" element gets
esz=16, replacing the primsize=0 → 8B fallback. Without this the
frame collapsed to 24B for [3]str.
Slice (24B), struct, tuple, tagged element arrays have the same root
cause but distinct width/layout concerns — deferred to #35 per rob.
Test 711 (arrlit_str_full): 7 rows × 2 stages = 14 fixtures —
str_lens_3el, str_ptrs_3el, str_repeat_5el (TK_ELLIPSIS), bool_3el,
rune_3el, i32_3el, i64_3el. Rune relies on the pre-existing esz==4
→ MOVL path (incidental correctness); sibling slot types pinned as
regression nets.
Followups filed: #34 (wwstage cgindex truncate on [N]str bare-let
read side, surfaced by this fix), #35 (composite element types),
#36 (primsize-returns-0-default-to-8 cleanup).
localoff (cstage) / localadd (wwstage) deduped stack slots by name
alone, ignoring scope. Outer `let a: [128]u8` and an inner-block
`let a: *u8` shared one 8B slot; prologue truncated to inner size
and outer-scope writes past saved RIP corrupted the frame. Worker-19
hit it during #19 (selfhost/cmd/w6a/main.ww carries a defensive
asm→s rename pointing at this task).
Drop the name-dedup. Each let allocates fresh. Then preserve
outer-scope visibility across inner blocks: cgstmt's N_BLOCK case
saves `*locals` head, walks body, restores. cgfn iterates fn->body
->list directly (bypassing the outermost N_BLOCK) so defers and the
implicit-return epilogue still see fn-body locals after the loop.
Wwstage symmetric: localadd keeps dedup only for `@`-prefixed
synthetic scratches (`@tagscr` / `@retscr` / `@tagbase`) which need
single-slot semantics; user names get fresh stubs. scanlocals always
counts + always appends a fresh stub for N_LET / N_MLET / N_FORRANGE
so prologue SUBQ stays in sync with emit-time offsets. cgblock and
cgfn mirror cstage.
ww2 == ww3 == ww4 byte-identical post-fix.
Test 709 (localoff_scope): 8 rows × 2 drivers = 16 fixtures —
inner_first_outer_bigger, outer_first_inner_writes, nested_3_deep,
same_name_diff_type, same_block_redecl_pin, defer_shadow,
forrange_body_shadow, if_body_shadow. defer_shadow pins the cgfn
body-bypass; if_body_shadow pins the save/restore independently.
Asm byte-id not diffed in 709 — 995_self_rebuild covers cross-stage
drift more broadly.
Follow-ups (filed): #32 (check: refuse same-block let-redecl), w6a
`s`→`asm` revert sibling commit.
When `use fmt;` is in scope and a local/param named `fmt` shadows it,
`fmt.X` in the body silently resolved to the str-typed value sym and
emitted `CALL AX` through str.ptr → runtime crash. Surfaced during
#15 (lib/log's printfln family); worked around by renaming the param
`fmt`→`format`.
Per rob + user, option (C): "value names and module names are
disjoint." Refuse the shadow at the decl site. Single rule, no
non-local reasoning, no silent footgun if a future lib/X exports a
new leaf.
cstage: src_imports walks file->list for N_USE entries (skipping
self-imports where u->module == u->str — same-module fixtures like
lib/fmt/fmttest.ww carry these); check_module_shadow runs before
each SK_PARAM / SK_VAR scope_define (param, clet, mlet, forrange
single + tuple, mcase). Wwstage mirror in check.ww; wwdump-only
diagnostic today, full enforcement waits on #11 checkfile pass.
Bootstrap byte-id holds — no codegen change. One source patch in
selfhost/cmd/w6a/main.ww renames an outer `let asm: asm_;` to `s` to
sidestep task #27 (cstage localoff scope-blind dedup); unrelated to
#19 but the new rule's first run flagged it as a self-shadow.
Test 708 (param_shadow_mod): 4 rows — neg_param (param shadow errs
at fn decl line), neg_let (let shadow errs at let decl), pos_rename
(rename compiles + runs), pos_selfimp (in-module use is skipped).
4 wired sites without dedicated rows deferred to task #28.
Follow-up: lib/log can revert format→fmt now that the silent
crash is impossible.
Top-level `def NEG: i32 = -100;` skipped DATA emission in both stages
— cstage's emit_defs and wwstage's emitdefconstants each carried a
literal-leaf whitelist that excluded N_UN nodes. Same gap in
check.c's eval_enum_value cstage-side. Surfaced during #10 (lib/os
forced an `at` enum for AT_FDCWD=-100 etc. as workaround).
Factor a single fold_int_literal helper (cstage check.c; wwstage
cgen.ww). Handles N_INTLIT / N_RUNELIT / N_TRUE / N_FALSE / N_NIL
plus N_UN with TK_MINUS / TK_TILDE / TK_PLUS recursively. Consume
from eval_enum_value, emit_defs, emitdefconstants, enumevalmember —
single source of truth for "is this a literal-leaf foldable".
Side effect: cstage's def-emit set widens from {INTLIT, RUNELIT,
TRUE} to match wwstage's pre-existing 5-shape set plus the new
unary peel. Bootstrap byte-id holds (995_self_rebuild green).
Test 631 (def_neg_global): 6 rows × cstage/wwstage run + asm
byte-identity diff. Covers all three unary arms (-, ~, +), positive
regression-pin, i32 + i64 + u32 slots.
Follows up #26: revert lib/os.ww `at` enum to three top-level defs.
Hare-shaped filestat introspection. New types: filestat (80B,
mirrors fs::filestat ref/hare/fs/types.ha:141), mode (31-member
enum mirroring fs::mode ref/hare/fs/types.ha:63), stat_mask (7 bits
mirroring fs::stat_mask ref/hare/fs/types.ha:129), timespec (i64+i64,
layout-compatible with future lib/time::instant).
APIs: stat / lstat / fstat (*filestat, *u8|i32) (void|oserror) over
SYS_newfstatat (nr=262). The out-param shape sidesteps the cgreturn
24B ABI cap; commented inline. exists(*u8) bool goes through the
syscall directly rather than wrapping stat()? — dodges task #22's
80B-scrutinee match-slot disagreement until that lands.
Three latent cgen workarounds in tree, all pointer'd to filed tasks:
#22: os.exists sidesteps the (void|oserror) match shape
#24: `at` enum bundles AT_FDCWD/SYMLINK_NOFOLLOW/EMPTY_PATH instead
of three top-level `def`s (negative-literal def DATA omit)
#25: kstat.mode typed as `mode` (enum) rather than u32 to skip the
redundant u32→enum cast emit
Tests: 976_stat_run, 9 rows — stat/lstat/fstat × regfile/dir/symlink
plus exists × {regfile,dir,noent}. Row 1 also pins perm-bit and
atime/mtime/ctime!=0 to catch silent kstat→filestat offset miscompiles
(kstat fields at 72/88/104).
Graduation to lib/fs when it ships is noted inline; signatures stay
rename-compatible.
Add os.alloc(n: u64) *void and os.free(p: *void, n: u64) void as
`export fn` via @symbol("rt_alloc") / @symbol("rt_free"). Signatures
mirror lib/memio's existing internal bindings byte-for-byte — only
the name and `export` keyword change. lib/memio + lib/shlex + lib/
getopt drop their own copies in a follow-up commit.
Doc comment spells out the actual failure ABI: rt_alloc wraps the
raw mmap syscall (no libc), so OOM yields a negative-errno cast to
`*void` (e.g. (void*)-12 for ENOMEM). Neither `== nil` nor the libc
MAP_FAILED `(void*)-1` value catches it; deref faults. A typed
fallible variant is future work (alongside #16 fmt.asprintf).
Test (ostest test_alloc_free_roundtrip, signalled=5): alloc 4096B,
write 0x5a at head + 0xa5 at tail, read-back asserts both, free.
The head+tail write/read prevents DCE (failure path calls os.exit)
and proves a real page is backing the returned pointer.
cgreturn's variant-widen arm only filled the registers each variant's
payload needed: scalar variants left CX and R8 stale; str variant
left R8 stale. The receiver (cg_widen_tagged_store non-N_IDENT
branch) writes all four ABI words to the dst slot unconditionally,
so caller-side residue in CX/R8 (the array-index IMULQ being the
canonical primer) landed at slot+16 and slot+24.
Worker-fmtparser surfaced this through fprintf's loop body where
array indexing primed CX and a 24B-return helper failed to clear
it; bug isn't loop-specific — straight-line repro at /tmp/wcrs_repro/
repro8.ww confirms.
Patch: emit `MOVQ $0, CX` after the variant's register shuffle when
the slot exceeds 16B and the variant doesn't fill CX; same for R8
when the slot exceeds 24B. Symmetric across cstage cgen.c and
wwstage cgenstmt.ww. Order: zero-MOVQs precede `MOVQ $tag, AX` so
AX-as-staging stays safe. Inline comment at cg_widen_tagged_store
non-N_IDENT branch documents the producer-zero contract.
Test 707 (cgreturn_variant_zero): 6 rows × both stages = 12 fixtures.
Covers scalar/bool/str returns after array-index priming in
straight-line / single-loop body / nested-loop body / mixed-variant
loop. Asm byte-identity check intentionally omitted; wwstage
taggedvariantindex divergence on str/bool N_IDENT is filed as task
#20. 995_self_rebuild covers the broader cross-stage drift surface.
ww2 == ww3 == ww4 byte-identical post-fix. 67/67 green.
Deferred (followups filed): #20 wwstage taggedvariantindex,
#21 [N]str/[N]bool array-literal non-pointer-half writes, #22
consolidate variant-widen into uniform scratch-slot path.
wwstage cgdot lacked a TY_FN branch for module-qualified N_DOT
rvalues. `let p = mod1.ping` fell through to the MOVQ/LEAQ-narrow
fallback, loading 8 prologue bytes from the fn's first instruction
instead of taking its address. Cstage cgdot already handled this
case (wired during #9, f1440bf).
Mirror cstage: gate on fnretlookup(c, fld) before the localloadop
fallback; emit `LEAQ <module>.<name>(SB), AX` via emitfnname with
the hint from lhs.str.
Extend 706_fnlabel_mangle: pos.ww now stores mod1.ping/mod2.ping
into local fn-pointer slots and dispatches through them in addition
to the existing direct calls. Expected exit 56 → 112. Regression
shape: without the new branch, MOVQ leaf(SB) loads the prologue
bytes; indirect call jumps into garbage → SIGSEGV.
ww2 == ww3 == ww4 byte-identical at the new emit.
Both stages emitted fn TEXT labels by leaf only; lib/os and lib/io
exporting the same leaves (read, write, close) collided at link.
lib/fmt + lib/log worked around with @symbol("rt_syscall") stubs.
Drop d->export from the fn skip rule in mod_collect (both stages) so
exported fns mangle as <module>.<name>. Let/def/type keep current
behavior. Skip retained for {@symbol, main, empty-module}.
Add cur_mod thread through cgfn + mod_lookup_for_fn(name, hint) at
all 4 label-emit sites (TEXT def, LEAQ N_IDENT, CALL N_IDENT, CALL
N_DOT). Wwstage mirror: emitfnname + modlookupforfn + curmod.
Invariant comment pinned in both stages.
ww2 == ww3 == ww4 byte-identical at the new label format.
706_fnlabel_mangle covers same-leaf cross-module CALL + private-leaf
cur_mod disambiguation through a fn-pointer rvalue.
Wwstage LEAQ-of-fn N_DOT (`let p = mod.fn` rvalue) is a pre-existing
gap; deferred to a follow-up. fmt/log rt_syscall stubs untouched
here; cleanup follows.
Sister bug to #17 / #18. The structlit-fill helper handled nested
N_STRUCTLIT field values but a struct-typed field whose VALUE is an
N_CALL (call returning a struct, #4 cgreturn ABI) fell through to the
cgexpr-then-AX-store path — landing AX=first qword and silently
dropping DX/CX. For 16B/24B inner returns the trailing 8B/16B stayed
zero (whatever was in the destination slot beforehand).
Fix: a new N_CALL+struct branch in cg_structlit_fill / cgstructlitfill,
placed between the nested-N_STRUCTLIT recursion and the scalar
cgexpr fallthrough. Emits cgexpr -> BX reload (non-BP modes only) ->
MOVQ AX/DX/CX x full + sized tail (MOVL/MOVW/MOVB) per #4's receive
shape.
INVARIANT (commented inline both stages): between cgexpr(N_CALL) and
the AX/DX/CX stores below, no instruction may touch AX/DX/CX. Only
the BX reload (MOVQ srcoff(BP),BX or LEAQ name(SB),BX) is safe.
Sized-tail dispatch is {1->MOVB, 2->MOVW, 4->MOVL, else MOVQ}. Unlike
the scalar fallthrough — which still uses the {1/4/else MOVQ} shape
to stay byte-identical with cstage pending #13 — the new branch is
correctness-by-construction: MOVW for tail==2 only fires on call-rhs
shapes that didn't compile before, and both stages emit it
symmetrically (705's 10B inner row pins this).
Guard `fsz <= 24 && fsz%8 in {0,1,2,4}` mirrors #4's cgreturn ABI:
>24B falls through (sret deferred), and fsz%8 in {3,5,6,7} would need
shift-store — also unsupported by #4. Filed as task #21 (covers both
cgreturn and call-rhs's identical gap).
Two #15 sidesteps, both documented inline:
1. wwstage's fi.fsz for an inner-struct field is slot-padded
(8-rounded), not natural — using it would emit 2x MOVQ where
cstage emits MOVQ+MOVL for a 12B inner. The new wwstage branch
uses structnaturalsize(csi) to recover the natural size, matching
cstage's fl->type->size (check.c hands the helper natural sizes).
This sidesteps #15 without touching its scope.
2. The outer struct's totsize diverges across stages when
maxalign<8 (wwstage rounds to 8 universally; cstage to maxalign).
The 705 test rows pin `x: i64` on the outer to force outer
maxalign=8, keeping BP offsets stable across stages. Test-side
sidestep only; also #15 territory.
Files:
- cmd/w6c/cgen.c cg_structlit_fill extended
- selfhost/cmd/wcc/cgenutil.ww cgstructlitfill mirror
- selfhost/cmd/{w6c,wwdump}/main.combined.ww auto-regen
- test/wcc/705_nested_call_rhs.c 8 rows, table-driven; pins cstage
exit + wwstage exit + .s byte-identity. Tail widths 0/4/2/1, dst
modes DST_BP + DST_PTR_LOCAL, shallow + 3-deep.
- Makefile 705 wiring
Test: 65/65 PASS. 994_w6c_ww + 995_self_rebuild PASS (byte-identity
holds — load-bearing).
Sister fix to #17. The BP-rel helper from #17 covered N_LET /
N_ASSIGN N_IDENT-lhs / N_RETURN; the four N_ASSIGN N_DOT-lhs
structlit walks still went through the inline `cgexpr(field.lhs);
store-AX-sized` shape and silently dropped trailing bytes when a
struct-typed field's value was itself an N_STRUCTLIT. Affected dot
flavors: single-dot via_ptr / global / BP-rel and the chained-dot
walker (depth >= 2, all three root flavors).
Extend `cg_structlit_fill_bp` / `cgstructlitfillbp` into
`cg_structlit_fill` / `cgstructlitfill` taking a destination mode
(DST_BP / DST_PTR_LOCAL / DST_GLOBAL = 0/1/2), srcoff (PTR_LOCAL),
srcname (GLOBAL), and disp accumulator. `disp` grows by foff on
descent; srcoff/srcname stay constant across the call tree. The
pre-#17 wrappers are preserved byte-identically by delegating with
mode=DST_BP — 995_self_rebuild byte-identity holds for the no-
nested-STRUCTLIT case that selfhost source actually uses.
The non-BP modes reload BX before the ELLIPSIS zero-fill loop AND
before every field store (tagged, scalar, and the cgexpr leaf).
This is correctness-by-construction — cgexpr clobbers BX between
fields, and the redundant reload only fires on shapes that didn't
compile before. The four dot-flavor sites in each stage now compute
their dst mode + disp and call the shared helper (reducing each
from ~80-130 inline lines to ~5-12 lines of dispatch).
Stage signature asymmetry: cstage threads Local** for cgexpr; ww-
stage takes explicit totsize because #15 (split totsize into
naturalsize + slotsize) is still pending and the dot sites need
structnaturalsize while the BP-rel sites need si.totsize. Both
asymmetries are documented in the helper docstrings.
704 covers 8 rows (24 checks: 8 cstage exits, 8 wwstage exits, 8
cstage-vs-wwstage .s byte-identity diffs): 6 dst-flavors (single-
dot local/ptr/global, chained-dot local/ptr/global) plus single-
local 3-deep and single-ptr 3-deep to pin disp threading through
the helper's recursion and through DST_PTR_LOCAL BX reloads.
The nested struct-typed CALL rhs in field-walks has the same shape
as the STRUCTLIT bug fixed here but the helper only handles
STRUCTLIT — tracked as task #20.
Pre-existing landmine surfaced by #5. For a struct literal whose
field value is itself an N_STRUCTLIT of a struct-typed field, the
inline field-walk did `cgexpr(field.lhs); store-AX-sized`. cgexpr
has no whole-struct-in-register convention, so the nested literal
landed AX = first qword and the trailing bytes silently stayed zero
(or stack garbage). Three BP-relative sites in each stage hit it:
N_LET, N_ASSIGN N_IDENT-lhs, and N_RETURN N_STRUCTLIT.
Fix: shared cg_structlit_fill_bp (cstage) / cgstructlitfillbp
(wwstage) helper handles TK_ELLIPSIS autofill, tagged-field
widening, float vs scalar store dispatch, AND recurses on
struct-typed N_STRUCTLIT field values at bp_off + field_off. All 3
sites in each stage now call the helper instead of the inline walk.
Scalar store dispatch is the explicit {1->MOVB, 4->MOVL, else MOVQ}
shape (not fieldstoreop, which would emit MOVW for fsz==2) to stay
byte-identical with cstage pending task #13. Sister N_ASSIGN N_DOT
structlit walks (via_ptr / global / BP-relative-through-N_DOT) keep
their inline walk and still drop nested-STRUCTLIT silently — tracked
as task #18.
703 covers 6 rows: let_nested_i64, let_nested_3deep, let_nested_i32,
let_nested_middle (i64; switch to i32 once #15 lands),
assign_ident_nested, return_nested. 995_self_rebuild byte-identity
preserved.
Pre-existing landmine surfaced by #5 (whole-STRUCT N_ASSIGN). Both
stages' N_DOT dispatch gated on `lhs->lhs->kind == N_IDENT`; the
parser produces N_UN(STAR, IDENT(p)) for `(*p).f`, so both sides fell
off:
- Write side (cgassign N_DOT base): emitted nothing, store dropped.
- Read side (case N_DOT pointer-auto-deref): cgexpr derefed the
pointer as a scalar, AX = first qword of struct, field offset
dropped.
Fix: retarget base / dot_lhs to the inner IDENT when shape is
N_UN(STAR, IDENT). The existing via_ptr branch fires identically to
`p.f`. v1 scope is bare-IDENT inner only; `(*expr).f` (non-IDENT
pointer expression) is tracked separately as task #19.
702 covers 7 rows: write_i64/i32/str, read_i64/i32/str_len, roundtrip
Auto-regen of derived files; lib/os.mkdirs landed in 19aa66a but the
selfhost combined.ww snapshots that fold lib/os in were not regened
in that commit. Catching them up now so the next make doesn't fight
the tree.
No source change; make test 61/61.
Receive side of #4's cgreturn ABI (aee8149) for TY_STRUCT lvalues of
size <=24B. Producer materialises rhs into AX=bytes[0..7], DX=[8..15],
CX=[16..23], zero-padded to 24B; receive sites here read the regs and
write only `declared sz` bytes — MOVQ for full 8B chunks plus a sized
tail (MOVL/MOVW/MOVB) by the *declared* struct size. ASYMMETRY: do NOT
mirror the sender's three uniform MOVQs, else trailing 1..7B chunks
overrun the next local slot. Tail chunks in {3,5,6,7} are unreachable
under WW struct align rules (size%align==0) and fall through.
Five sites wired in each stage (cstage cgen.c, wwstage cgenexpr.ww +
cgenstmt.ww), call-result + structlit rhs at each:
- N_LET `let s: T = bar()` / `= T{...}` cgenstmt cglet
- N_ASSIGN N_IDENT-lhs `s = bar()` / `= T{...}` cgenexpr cgassign
- N_ASSIGN single-DOT local-base `o.f = ...`
- N_ASSIGN single-DOT ptr-base auto-deref `p.f = ...`
- N_ASSIGN single-DOT global-base `g.f = ...`
- N_ASSIGN chained-DOT depth>=2 `o.m.in = ...`
(The four dot-flavors share one shape pattern, hence "5 sites".) Where
the dst addr needs scratch (ptr-base/global-base/via_cx), it is loaded
into BX after the call so CX stays as the third value word; for
structlit field-walks BX is reloaded before each store since cgexpr
clobbers AX/BX between fields.
wwstage needed a new `structnaturalsize(si)` helper (cgenutil.ww):
si.totsize is mis-named — it's slot-padded to 8 by registerstruct for
stack-slot use, while the receive ABI wants the type's natural size
(max(foff+fsz)). Splitting si.totsize into naturalsize + slotsize is
tracked as the wwstage struct sizing follow-up (task #15); until that
lands, the helper recovers the natural size at receive sites.
Test 701_cgassign_struct.c (18 rows, 3 checks each — cstage value,
wwstage value, asm byte-identity), wired in Makefile after 698. The
headline ASYMMETRY case is the 20B `{i32×5}` row: sender pads to 24B
via three MOVQs, receiver writes MOVQ AX +0, MOVQ DX +8, MOVL CX +16.
A regression to a MOVQ tail there overruns 4B past the slot and
flips the exit-code check.
smoke.combined.ww is the auto-regen ride-along of strings.freeall
landing in 714d089 (worker-shlex).
Pre-existing gaps surfaced and tracked separately (not fixed here,
out of scope):
- task #16: silent drop of `(*p).f = ...` explicit-deref dot lhs.
- task #17: silent zero of nested STRUCTLIT field in N_LET / N_ASSIGN
initializer — the field_chain and field_global test rows use
explicit field writes (`o.m.t = 10i64;`) rather than nested
literals as a fixture-level workaround.
- task #9: module-name-mangle for fn labels avoided in the
field_global_call fixture by `let g: outer;` (no init).
make test: 59/59. 994_w6c_ww + 995_self_rebuild PASS — bootstrap
byte-identity is the load-bearing proof for this commit's scope.
Whole-struct return ABI for sizes <=24B. Both stages materialise rhs
into a zero-padded 24B @retscr scratch slot, then load AX=bytes[0..7],
DX=bytes[8..15], CX=bytes[16..23] unconditionally — three MOVQs
regardless of declared struct size, so the receive side (landing in
task #5) can read all three words and mask by the declared size. R8
stays reserved for the tagged-return 4th word; the uniform-MOVQ shape
is cheap over a size-conditional partial-load and keeps the producer
diff vs the existing tagged-return AX/DX/CX/R8 path minimal.
Two rhs shapes wired this pass: N_IDENT (word-copy from rhs local slot,
MOVQ pairs + MOVL/MOVB tail bounded by declared struct size) and
N_STRUCTLIT (field-walk; tagged fields delegate to the existing tagged
widening helper, float fields go through X0, int fields use MOVQ/MOVL/
MOVB by field size). Sizes >24B fall through to the existing scalar
path (only AX gets the first qword), pending sret in a future task.
N_CALL chain-return (`return otherfn()`) is deferred to task #5's
receive side — until that lands the call-result lives in caller regs.
The wwstage mirror in cgenstmt.ww matches cgen.c byte-for-byte on the
new branch; cgendecl.ww's scanlocals pre-reserves 24B for @retscr under
the same predicate (N_RETURN, fnret is N_TNAME, structlookup hit,
totsize<=24, rhs is N_IDENT|N_STRUCTLIT) since wwstage writes its
prologue SUBQ from the upfront frame total — cstage patches SUBQ at fn
end so it can allocate inline.
Latent fsz==2 MOVW divergence between stages (cstage structlit int-
branch only special-cases fsz 1/4, wwstage's fieldstoreop also returns
MOVW for fsz==2) tracked as task #13; not exercised by the new fixtures
or by any current selfhost <=24B struct return.
main.combined.ww files also pick up worker-checkfix's wwstage
architectural comment from 7f60ebb (auto-regen ran after that commit).
In cmd/wcc/check.c the pass-1.5 SK_USE→SK_DEF/SK_FN/SK_VAR promotion
sites forgot to set prev->use_alias = 1 when the imported module's
top-level decl shadowed the SK_USE leaf in flat scope. Downstream
dot-prefixed lookups (resolve_typename L77, N_DOT L709) gate the
module-head walk on (SK_USE || use_alias), so `mod.flag` resolution
fell through to "unknown type". The SK_TYPE precedent at L1660 had
the line; the three sister sites at L1709/L1722/L1736 now do too,
in the same one-line shape and field-set order.
The wwstage selfhost/cmd/wcc/check.ww uses coexistence rather than
in-place promotion: SK_USE and same-leaf SK_TYPE/FN/DEF/VAR live as
separate entries differentiated by sym.mod, and scopelookupinmodule's
mod-filter already disambiguates dotted lookups — no use_alias flag
needed, so the cstage bug is structurally non-reachable there. An
architectural note at installdecl documents this divergence-by-design
and warns against porting the flag (adding a field to `sym` changes
its size and risks the wwstage cgen amalloc-undersize trap).
Audit covered every SK_USE→SK_X promotion path in check.c (4 sites:
SK_TYPE already-correct as precedent, SK_DEF/SK_FN/SK_VAR fixed). The
surfacing case was lib/fnmatch: `fn fnmatch(...)` shadows the SK_USE
leaf, so `fnmatch.flag` failed in worker-fnmatch's WIP — that test
(972_fnmatch_run) now flips PASS as live integration proof.
test/wcc/699_use_promote_alias.c pins all four rows with a single
table-driven driver (type/fn/def/var → use mod; let m: mod.flag =
mod.flag.A; return m: i32, expecting exit 42 per row). 995_self_rebuild
byte-identity holds.
Tags structinfo/enumtype with originating module; exact-match first,
then split pkg.X and filter by smod/emod. Without this, two modules
with same-leaf-name struct/enum types collapsed to whichever entry
appeared first in the chain.
Wired into 696_modtype_leaf_collision via a wwstage run_pos using
ww_ww (negative case omitted: w6c_ww has no checkfile pass). Updated
the test's Makefile deps to include the wwstage binaries.
Audited the rest of the lookup family — fnretlookup, fnparamslookup,
deflookup don't need the same treatment: the parser emits N_DOT.str
(call/field name) as the leaf only, and fnparamslookup is only
invoked with N_IDENT.str. Dotted module-qualified function calls go
through the module-mangling path instead.
Match-arm bind size in wwstage hardcoded str=16, []T=24, else=8 in
both cgmatch (emit) and scanlocals (frame pre-scan). A TY_STRUCT
variant fell into the 8B fallback: only the first quadword reached
the bind, and the prologue SUBQ underbooked the frame so the
emit-time localalloc(bsz=24+) wrote past SP.
Replace the hand-rolled table with slotsize(c, pat) at both sites.
slotsize already covers N_TNAME named structs (returns si.totsize),
str (16), []T (24), tuples, aliases, and primitives (8). Mirrors
cstage cgen.c cgmatch which falls through to bu->size for TY_STRUCT.
Cstage is correct; no mirror needed.
Test 695 covers seven shapes: the 3xi64 headline repro, a 4xi64
struct via let-init scrut (exercises >24B bind), a mixed-quadword
struct (i32+i32+i64+i64), str/slice/i32 negative controls, and a
direct let-init scrutinee variant. The wider 4xi64 row uses the
let-init shape because the N_DOT spill path in cgmatch tops out at
AX/DX/CX/R8 — a separate, unrelated gap from the bind size.