Commit Graph

470 Commits

Author SHA1 Message Date
5e4d67d90a check: widen const def-ref to declared int type in def init (#113)
A def initializer whose rhs references another def -- `def INT_MIN: int
= I32_MIN;`, `def SIZE_MAX: size = U64_MAX;` -- failed to compile: an
N_IDENT->SK_DEF types as the referent's DECLARED type (i32, u64), so the
def-init assignability check (type_assignable) rejected i32 -> int /
u64 -> size, even though the value is a compile-time constant that fits.
This blocked faithful types/types::c limit defs (no cast in the Hare
source).

In a def initializer the rhs is a flexible constant. When it folds to a
compile-time integer (the #88 eval_def_const path: sibling/imported def
refs, casts, arithmetic) and the value fits the declared integer target,
re-flexibilize it to UNTYPED_INT so the existing untyped-int->typed
assignability path accepts it. This emulates Hare's flexible-constant
promotion (ICONST -> promote_flexible/lower_flexible,
ref/harec/src/types.c:860); def_cast_fits is the range check that keeps a
genuine out-of-range narrowing a loud "not assignable" error, never a
silent truncation (rule 7). It is strictly the const subset: the general
CONCRETE (non-const) integer widening Hare does at types.c:1021-1037 is
intentionally stricter in ww -- #115.

cstage-only: the wwstage checker (selfhost/cmd/wcc/check.ww, "let init /
return assignability") intentionally never checks def-init assignability
(it stays quiet, leaving full inference to the C side), so it never
rejected the widening -- the #88 stamp already laid the correct DATA row.
Relaxing the cstage aligns the richer side DOWN to the leaner side
(rule 10); both stages stamp the identical folded value, so emitted asm
is byte-identical. The bootstrap corpus has zero cross-prim-width def-ref
defs, so the new path is dead there and 990-997 are unperturbed.

Coverage: test/wcc/760_def_widen_const (i32->int neg, u64->size, byte-id
on each, cstage-only out-of-range narrowing fail-loud).
2026-05-26 02:39:35 +09:00
9a265a31f5 wcc: name-bind the size type in type position (#85 fold-2)
Resolve `size` -> TY_SIZE at the type-name resolver (C lookup_builtin /
ww tinfofornode's N_TNAME chain), mirroring uintptr, both stages. This
makes `size` writable as a type (`let x: size`, struct field, etc.),
the prerequisite for lib/types SIZE_MAX.

Twins every NAME-keyed uintptr arm in the wwstage so it behaves like
the cstage's kind-keyed Type switches (already TY_SIZE-aware from
fold-1): primtypesize + astalign (8B/8-align), primsize + letscalarprim
(8B scalar slot), isinttypeast + isnumerictname (int/numeric). rule-10
symmetric; dead on the size-free selfhost corpus so 990-997 stay byte-id.

Coexists with the size(T) size-of operator (separate c.top SK_FN seed +
N_CALL fold, NOT a type path) and `.size` field access (N_DOT); neither
touched. No c.top SK_TYPE "size" seed (would collide with the operator
seed at check.ww:96). Regenerates w6c/wwdump combined.ww (checker
embedded). New probe 957_size_type_run exercises type-position `size`
and the operator in one scope.
2026-05-26 01:55:54 +09:00
bd7181ae1f wcc: add the size primitive type (TY_SIZE), classify as unsigned int (#85)
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).
2026-05-26 01:26:03 +09:00
68dbb77d6c test: combined.ww freshness gate in phase-2 (#110) 2026-05-26 01:07:58 +09:00
529eb51af3 selfhost: resync smoke.combined.ww to current lib/types (#110)
#28 regen'd the 5 cmd combined.ww but missed smoke, which pulls lib/types transitively. Baseline for the freshness gate below.
2026-05-26 01:07:25 +09:00
330792b884 lib/math: checked sat_subu* unsigned saturating subtract (#28)
Port the deferred sat_subu8/16/32/64 from
ref/hare/math/checked/saturating.ha:196,206,216,226 — clamp to
types.U*_MIN (0) on underflow. Mirrors the existing sat_addu* shape
(typed res forces the sub-word wrap, then the >a underflow test).
Tests: sat_subu* normal+clamp rows plus a direct types min check
(U*_MIN==0, RUNE_MIN=='\0'), wired into checked_test main().
2026-05-26 00:48:19 +09:00
c40b2df097 lib/types: add U*_MIN and RUNE_MIN
Mirror Hare's types::limits U8_MIN..U64_MIN (all 0) and RUNE_MIN
('\0'), ref/hare/types/limits.ha:30,36,42,48,54. Pure literals,
byte-id-neutral; the U*_MIN unblock checked sat_subu* which clamp to
types.U*_MIN.
2026-05-26 00:35:53 +09:00
4578ce98a0 selfhost: resync w6c/wwdump combined.ww to current wcc source (#109)
Catch-up regen only; no source change. w6c and wwdump embed the wcc cgen, whose post-#97 edits landed without regenerating these two tools' combined.ww -- the byte-id gates are freshness-blind, so master stayed green while shipping a stale artifact. Permanent freshness gate filed as #110.
2026-05-26 00:33:50 +09:00
f12676b880 test: 952 add frexpf64 runtime rows 2026-05-25 16:48:14 +09:00
9311e6ca4e lib/math: math::floats fold-2a frexpf64 decompose 2026-05-25 16:48:14 +09:00
b4752aad21 test: 952 add issubnormalf64 + normalizef64 runtime rows
Four cstage build+run rows for the fold-2a decompose step: issubnormalf64
across normals/zero/a constructed subnormal (f64frombits(1u64)), and
normalizef64 on both the normal path (n, 0) and the subnormal multiply
path (exp -52, result no longer subnormal). normalizef64's (f64, i64)
return rides the #102 16B-tuple-from-call receive; tuple-field f64s are
spilled to a let before any literal comparison to dodge the same
gate-blind XMM mis-load that holds back frexpf64 (see 952 header +
lib/math/floats.ww). Byte-id of a normalize-calling program is covered by
954's tuple-receive gate.
2026-05-25 16:43:25 +09:00
263257f2c1 lib/math: math::floats fold-2a issubnormalf64 + normalizef64
Ports the subnormal-normalize step of the f64 decompose half from
ref/hare/math/floats.ha: issubnormalf64 (floats.ha:179) and normalizef64
(floats.ha:256, the f64-multiply-on-subnormal that yields (f64, i64)).

frexpf64 (floats.ha:278) is held back, not ported: its Hare-exact zero
guard `n == 0f64` miscompiles. A no-decimal `0f64` literal used as an f64
comparison operand is materialized into a GPR and never moved to XMM, so
the UCOMISD reads a stale operand and `n == 0f64` is wrong for every n.
Both stages emit this identically, so the byte-id gates are blind to it.
`0.0` compiles correctly but substituting it would be a workaround
(rule 7), so frexpf64 waits for the cgen fix. normalizef64/issubnormalf64
touch neither the broken literal form nor any tuple-field comparison, so
they are correct and land now.
2026-05-25 16:43:25 +09:00
8182342a9d test: 956 branched f64-tuple receive across all 3 forms, run + cs==ww byte-id (#105)
Covers single-var (cglet), destructure (N_MLET), and reassign (N_MASSIGN)
receives, each with a BRANCHED callee whose f64 word is an f64 param
(single-return + float-literal masks #105 via AX bit-coincidence). 7 bug
rows: single-var (f64,i64) + deferred-read-after-X0-clobber + (i64,f64)
order-swap; destructure (f64,i64) + order-swap; reassign (f64,i64) +
order-swap. 4 controls: all-integer branched, destructure-with-no-f64,
#103 FACE-Z single-return field read, #103 FACE-X 0f64 compare. Each row
asserts cstage build+run exit AND w6c vs w6c_ww .s byte-identity. The bug
is gate-blind to byte-id alone (all three forms symmetric-wrong on
master, no .s divergence); the cstage-run check catches it. FAILs on
master 4c4006d (7 bug rows), PASSes on the fix (11/11).
2026-05-25 16:23:47 +09:00
ec19d0ad20 cgen: tuple receive spills f64 word from XMM, not integer reg (both stages, #105)
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.
2026-05-25 16:23:47 +09:00
4c4006d854 test: 955 add negative-f64-lit compare rows (#103 FACE X)
-8f64 is N_UN(TK_MINUS) wrapping a float-typed N_INTLIT; the wwstage
exprfloatkind must recurse through the unary into the N_INTLIT-float
arm. Two rows: a true-case catcher (g(-8.0) == -8f64 -> 1; pre-fix the
literal never reaches X0 so the compare is always false and g(-8.0)
wrongly returns 0, plus a cs!=ww .s divergence) and a false-case
control (g(8.0) -> 0). Both pass byte-identically on the fix.
2026-05-25 15:38:47 +09:00
0388cb7a26 test: 955 f64 XMM-materialise run + cs==ww byte-id (#103)
Table-driven, modelled on 953: each row does (a) cstage build+run+exit
and (b) w6c vs w6c_ww .s cmp. Rows cover FACE X (0f64 compare false +
true case, 8f64 arith) and FACE Z (tuple-field f64 compare) plus the
let-bound control that was already correct. Fails on master e784968
(x_cmp_false 1!=0, x_arith 100!=80 + byte-id divergence, z_tuple_field
5!=9); passes on the fix.
2026-05-25 15:26:48 +09:00
c9e39c6782 cgen: f64-typed int-literal + tuple-field materialize in X0 (both stages, #103)
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).
2026-05-25 15:26:43 +09:00
e784968dd8 test: 954 add struct-exclusion control row (#102 over-catch guard)
The fix's new cglet branch is gated on sz==16 AND rettupleof != nil. The
existing rows prove it CATCHES 16B tuple receives but nothing pinned that
it does NOT catch a 16B struct{i64,i64} receive (same sz==16, but
rettupleof returns nil for a non-tuple return). A future rettupleof
refactor returning non-nil for a struct would silently route the struct
through the AX/DX tuple spill and miscompile it, gate-blind. The new
ctl_struct row is byte-id and runs correctly (exit 12) both pre- and
post-fix, pinning the tuple/struct discrimination.
2026-05-25 14:45:04 +09:00
734c0c36e5 test: 954 16B tuple-from-call receive cs==ww byte-id + cstage run (#102)
Per-row: cstage build+run exit assert AND w6c vs w6c_ww .s cmp.
Three scalar-pair single-var bug-rows — (f64,i64), (i64,f64) order-swap,
(i64,i64) all-integer — each diverge on master 60c3e51 by one dropped
MOVQ DX and are byte-identical after the fix. The all-integer row proves
the fix is not f64-gated. Two control rows stay byte-id pre- and
post-fix: the destructure form `let (a,b) = mk()` (cgmlet + tupstore
cursor, the path bootstrap/990-997 rely on) and a 32B str-element
single-var tuple (excluded by the fix's sz==16 gate). Modelled on
953_f64crossmod_run. Master gate: 3 bug rows FAIL, 2 controls clean.
2026-05-25 14:32:24 +09:00
6a586cf792 cgen: wwstage cglet spills both eightbytes of a 16B tuple-from-call receive (#102)
wwstage-only. cglet had no 16B whole-tuple-from-call receive branch, so
`let t = call()` whose callee returns a 2-eightbyte (16B) tuple fell
through to the generic single-word store (MOVQ AX, off(BP)) and never
spilled word1 (the DX eightbyte) — silent loss of t.1. Align to cstage
cgen.c:6652, which spills both AX->off+0 and DX->off+8.

Not a tupstore cursor off-by-one and not f64-specific: the destructure
form `let (a,b) = call()` (cgmlet + tupstore cursor) was already byte-id;
only the whole-tuple N_LET receive dropped word1, for any element mix
incl. all-integer (i64,i64). An f64 element surfaced it first. The f64
element rides its eightbyte in AX/DX at receive and is re-read from
X0/XMM at field-read (already byte-id), so no SSE cursor is needed.
2026-05-25 14:29:34 +09:00
60c3e51dc5 test: 953 add #98 XMM-pressure row (myf.g() as 1-of-3 f64 args, MOVSD->X2)
Basic x98_nested_arg only spills the imported f64 call-result into the
first float slot (X0); a multi-f64-arg call forces the spill into X2
while two more f64 args are live, exercising pushargsrev's float spill
under XMM register pressure — a distinct path. Verified .s diverges on
master 6f8b658 (integer PUSHQ AX/POPQ DI for the myf.g() arg) and is
byte-identical post-fix; cstage run exits 8 (sum3(-7,10.5,4.5)=8.0:i32).
(#98)
2026-05-25 14:04:07 +09:00
ab22900ad1 test: 953 f64 cross-module return cast/arg cs==ww byte-id + cstage run (#98/#101)
Regression net with two dimensions per row because cstage is correct
before and after the fix — a cstage-only probe is gate-blind to a
wwstage-only divergence:
  (a) cstage `ww build` + run, asserting the truncated exit code;
  (b) w6c vs w6c_ww `.s` cmp, FAILING on any rule-10 divergence.

Rows cover #101 (`mod.g(): i32`, neg + both truncation directions) and
#98 (`dbl(mod.g())` forwarding an imported f64 call-result as an f64
arg). Verified: dimension (b) FAILS on master 6f8b658 (pre-fix wwstage
emits MOVSXD/integer-PUSHQ) and PASSES post-fix (byte-identical);
dimension (a) passes on both, confirming cstage was always correct.
2026-05-25 13:51:13 +09:00
7b3abf0ec5 cgen: wwstage fnretlookup resolves cross-module callee f64 return type (#98/#101)
exprfloatkind's N_CALL arm only set the callee name for an N_IDENT
callee, so a module-qualified `mod.g()` callee never reached any
return-type lookup and fell through to integer (kind 0). Both f64
consumers then took the integer path for an imported f64-returning
fn: cgcast emitted MOVSXD instead of CVTTSD2SI (#101), and pushargsrev
spilled the call result as a GPR PUSHQ/POPQ instead of the MOVSD float
spill (#98) — one root, two symptoms.

Route the N_DOT callee through fnretlookupmod with the module
qualifier, mirroring nodeisslice / nodeisstr's #34 N_DOT arm, so a
cross-module f64 call resolves to kind 2 exactly like same-module
already does. This aligns wwstage UP to cstage, whose cg_isfloat reads
the resolved call result type directly (cmd/w6c/cgen.c:117,155) and is
correct for both cases. Consumers (cgcast, pushargsrev) unchanged.
2026-05-25 13:48:31 +09:00
6f8b658c17 lib/math: inline floats f64bits/f64frombits reinterpret deref
Mirror Hare's single-expression `*(&n: *T)` structure (CLAUDE.md
rule 12) instead of a let-temp two-step that added a binding Hare
has no counterpart for. Document the load-bearing parens (rule 8):
ww's `:` cast binds tighter than unary `&`, so the bare Hare form
parses as `*(&(n: *T))`; `(&n): *T` is what reinterprets the address.

ref/hare/math/floats.ha:5,11. Byte-identical both stages; 952 6/6.
2026-05-25 13:29:35 +09:00
389f314473 test: math::floats fold-1 runtime regression 2026-05-25 13:17:47 +09:00
253f13fa3b lib/math: port math::floats fold-1 (f64 classify/sign/bits) 2026-05-25 13:14:10 +09:00
ec184aade7 test: f64 cgen deref-load + NaN-compare runtime regression (#96/#97)
#96 and #97 are GATE-BLIND: both stages emit byte-identical asm before
and after the fix, so the 990-997 byte-id gates can never catch a
reintroduction. Only an executed-and-checked runtime probe can. Adds
test/wcc/951_f64cgen_run.c, a table-driven cstage build+run harness
(modeled on 700_e2e) asserting exit codes.

Coverage: #96 f64/f32 deref-load (MOVSD/MOVSS into X0) via bare *p, f64
return through a fn, arith-through-deref, f64frombits reinterpret round
-trip, and copysign-style sign transfer; #97 the full 6-relop NaN sweep
(UCOMISD + UCOMISS), isnan true/false, a NaN-relop true-count value
assert, and the untouched >/>= left-bare arm. Verified the suite fails
on master 0d1ae17 (7/9 rows) and passes on the fix (9/9).

cstage-only by design (mirrors 700_e2e + 969_checked_run): ww_ww run is
broken (#95) and per-program wwstage byte-id is the 990-997 gates' job.
2026-05-25 13:04:14 +09:00
fa136d0b88 cgen: f64 compare consults parity flag for NaN, 4 relops (both stages, #97)
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.
2026-05-25 12:44:56 +09:00
2f2a73bd41 cgen: f64 deref-load -> MOVSD/MOVSS into X0 (both stages, #96) 2026-05-25 12:38:57 +09:00
0d1ae17dd0 check: def rhs const-fold resolves sibling/imported defs + casts (#88)
ww top-level def rhs const-fold was literal-only (fold_int_literal at the codegen emit-defs step), so a def referencing another def, an imported def, or a cast was inexpressible -- blocking faithful types/types::c/math/strconv ports whose defs cross-reference.

Fold at CHECK time: a recursive eval_def_const (pass-2 N_DEF arm, both stages) resolves N_IDENT/N_DOT via the checker's existing scope lookup to the target def's rhs, evaluates N_BIN through a shared fold_binop core (factored out of eval_enum_value so both compile-time-int-eval paths share one wrap/shift/divide table), strips identity/widening casts, and stamps rhs -> N_INTLIT. cgen is UNTOUCHED -- its existing literal-emit lays the DATA row. Gated to fire only when the plain literal fold fails, so existing defs keep their node and emitted asm is byte-identical (990-997 unperturbed by construction).

Guards (rule 7): recursion depth cap fails loud on a def cycle (same/cross-module); a narrowing cast (rhs outside target range) fails loud rather than silently truncating. Both stages' eval_def_const stamp identically (shared fold_binop semantics) so the substituted literal -- and byte-id -- holds across stages (rule 10, at the check pass).

a1 (same-module) + a2 (cross-module imported def) land together: the driver concatenates imports into one flat scope. Coverage: test/wcc/732_def_const_fold.
2026-05-25 11:27:00 +09:00
b7e1ad1a4b lib/math: port math::checked (overflow + saturating arithmetic)
Port Hare's math::checked to lib/math/checked/ as a two-file module
mirroring the upstream split:
  - checked.ww   (ref/hare/math/checked/checked.ha): add*/sub*/mul*
    returning (result, overflow) with wrapping semantics — addi/addu/
    subi/subu 8-64 and muli/mulu 8-32 (22 fns).
  - saturating.ww (ref/hare/math/checked/saturating.ha): sat_* clamping
    to the type's range on overflow — sat_addi/addu/subi 8-64 and
    sat_muli/mulu 8-32 (18 fns).
checked_test.ww drives the verbatim Hare @test vectors (crash-trick
idiom) via cross-module tuple-return destructure for the overflow fns;
wrapped by test/wcc/969_checked_run.c. Both stages emit byte-identical
asm; make test-unit green.

Three ww adaptations vs Hare, all forced by language differences, none
behavioral (documented at the sites):
  - no if-as-expression -> `return if (c) X else Y` becomes if-stmt.
  - no implicit integer promotion -> the mul overflow compares use an
    explicit widening cast.
  - sub-word arithmetic truncates only on store to a typed lvalue, so
    unsigned overflow tests force the wrap through a typed `res`.

Deferred as faithful Hare-subsets (Hare splits per type; no inlining):
  - size-typed *z variants: no `size` type yet (#85).
  - int/uint native-width variants: ww int/uint are 64-bit, a silent
    overflow-boundary width divergence.
  - 64-bit muls (muli64/mulu64/powi64, sat_muli64/sat_mulu64) and the
    muli/mulu dispatchers: need math::mulu64 (128-bit product).
  - sat_subu8/16/32/64: need types::U*_MIN, not yet in lib/types.

Saturating sat_* reference the types limits at RUNTIME (conditional
return, not a const-initializer), which resolves cross-module today
(#88 is const-fold-only). subi64's I64_MAX/I64_MIN boundary @test vector
is omitted while #89 is open (its I64_MIN literal miscompiles on
wwstage); the saturating I64_MIN assertions use the types.I64_MIN
def-ref, which is byte-id clean.
2026-05-25 11:16:25 +09:00
5a0427ef32 cgen: N-ary tuple destructure positional store + loud-stop (both stages, #83)
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.
2026-05-25 10:08:56 +09:00
1304db8871 cgen: *p=sliceval deref store -> 3-word (both stages, #79)
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.
2026-05-25 03:13:48 +09:00
ab9de65b8e cgen: sub-slice ptr = base + lo*esz (both stages, #76)
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.
2026-05-25 02:36:54 +09:00
8b23ff3517 cgen: sub-slice cap = base_cap - lo
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.
2026-05-25 01:59:14 +09:00
324df92e1d cgen: unify cstage slice-let onto shared 3-word store path
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).
2026-05-25 01:04:09 +09:00
451e2ebec9 cgen: slice-elem store/read -> 3-word via kind-OR (both stages)
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.
2026-05-25 00:20:16 +09:00
7bb40d924f cgen: merge byte-identical str+slice cgassign arms onto kind-gates -- Phase 2 C4.4
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).
2026-05-24 22:39:04 +09:00
3637993bb2 cgen: collapse redundant TY_STR esz special-cases onto str.sub -- Phase 2 step-3 Fold 2
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).
2026-05-24 18:01:32 +09:00
3d7c707bd2 cgen: ww str for-range loop-var narrows to MOVZBQ -- Phase 2 step-3 Fold 1 (align-up)
Ranging a str (for (let b .. = s)) and reading the loop var back emitted MOVZBQ on cstage (correct u8 zero-extend) but MOVQ on wwstage (the missed case, #14). Align wwstage UP. ww cgforrange derived the element-type node only for slice/array; for a str scrutinee it left elemt=nil, so the loop var registered with no type and localloadop short-circuited to MOVQ. Fix: for a str scrutinee, synthesize a u8 element node (type_ = str.sub = u8, from F1) as elemt, so localadd hands the loop var a u8 tnode and the GENERIC narrow-load fires (MOVZBQ) -- consuming str.sub as F1 intended, mirroring how []u8 supplies its element node. NOT an if-str special-case. cstage already correct, untouched (ww-only). str's own type stays nominal.

GATE is the ASM SHAPE byte-id (cstage==wwstage at the loop-var read), NOT a runtime probe: the divergence is runtime-benign (MOVQ and MOVZBQ read the same zero-extended byte) so a runtime test passes both ways and cannot distinguish -- it was a byte-id-INVISIBLE divergence (990-997 green despite cstage!=ww, since no bootstrap input exercises a narrow-read str loop var). Verified fail-pre (the cstage-MOVZBQ vs wwstage-MOVQ 1-line diff) / pass-post (.s byte-identical). []u8/slice/array for-range emission unchanged. test/wcc/940 carries the fixture (runtime corpus coverage, both drivers).

main.combined.ww regenerated via the canonical make path.
2026-05-24 17:29:20 +09:00
80527f3868 cgen: str a,s=call() N_MASSIGN tuple-elem store -> 3-word -- Phase 2 G3 (both stages)
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.
2026-05-24 16:54:14 +09:00
b51a7daa25 cgen: str chained <expr>.field = v store -> 3-word -- Phase 2 G2 (both stages)
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).
2026-05-24 16:18:49 +09:00
c6929231dc cgen: str arr[i].field = v store -> 3-word -- Phase 2 G1 (both stages)
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).
2026-05-24 15:35:37 +09:00
c3bbe17163 cgen: str arr[i].field read -> 3-word -- Phase 2 C4.6 arrfield (both stages)
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.
2026-05-24 14:57:05 +09:00
877a1af6d3 cgen: str tuple-positional element read -> 3-word -- Phase 2 C4.6 S3 (both stages)
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).
2026-05-24 14:22:25 +09:00
634cbefc22 cgen: str chained-*struct field read -> 3-word -- Phase 2 C4.6 caseB (both stages)
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).
2026-05-24 13:57:20 +09:00
90deeb3821 cgen: str N_DOT field read -> 3-word {ptr,len,cap} -- Phase 2 C4.6 (both stages)
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.
2026-05-24 13:31:58 +09:00
97707155ae cgen: str N_INDEX read -> 3-word {ptr,len,cap} -- Phase 2 F2 (both stages)
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.
2026-05-24 12:27:15 +09:00
fb4c567e0d wcc: populate str.sub = u8 -- Phase 2 F1 foundation (both stages)
str IS []u8 (#1 landed the 24B layout); F1 populates the element type
so the step-3 checker collapse can read str.sub instead of special-
casing TY_STR. No reader consumes str.sub yet, so this is byte-id-
neutral: every shared ->sub reader a TY_STR value can reach is
invariant under NULL->u8 -- u8 is unsigned + size-1, matching the
prior NULL-defaults (size->1, signed->0, isstr/istagged->false); the
only ->size derefs are guarded behind esz>1, which stays false for
str.

Verified inert: compiling a fixed source with the pre- and post-F1
compilers emits byte-identical asm on both stages; cross-stage
byte-id holds and full make test (135 tests incl. 990-997) is green.

cstage cmd/wcc/type.c, wwstage lib/ww/typ.ww; combined.ww regenerated
via the canonical make path.
2026-05-24 09:34:54 +09:00
4db680690a test/wcc/928: runtime coverage for str byte-index (C2) + str-into-tagged-union (C4.8) 2026-05-24 09:19:20 +09:00