Re-sweep the whole C suite for remaining unsigned-semantics gaps
- Type: feature (test coverage / bug hunt) — Track A (shared codegen) + C frontend
- Status: done (2026-07-02)
- Opened: 2026-06-30
- Found by: pattern from two just-fixed unsigned bugs ([[bug-c-unsigned-int-32bit-arithmetic-semantics]] v91, [[bug-c-unsigned-div-mod-32bit-backends]] v92). Both were the same shape: signedness/width silently lost. The suite was never swept specifically for unsigned correctness against a gcc oracle, so more of the same is likely.
Why
The two fixes exposed a recurring class of defect, not a one-off:
- x86-64: integer ops run in 64-bit registers with no 32-bit truncation, so an
inline
unsigned intvalue keeps the wrong high bits unless masked. - 32-bit backends (i386/arm32/riscv32): scalar ordinal ops were hardcoded signed (compares, div, mod), ignoring operand type.
Each was only caught because someone happened to write the repro. A systematic sweep — every C operator/conversion exercised on an unsigned value with bit 31 set, diffed vs gcc on all five targets — will surface the rest in one pass instead of one-at-a-time.
Suspected remaining gaps (verify each vs gcc, all targets)
Each is a hypothesis to confirm/refute with a minimal repro, NOT a known bug yet.
- Right shift
>>of unsigned must be LOGICAL (srl/shr/lsr), of signed must be ARITHMETIC (sra/asr). Check(0x80000000u >> 1)== 0x40000000 (not 0xC0000000) inline on every target. The C frontend maps>>totkIdent(srl) already, but confirm it is not re-tagged signed anywhere and that 32-bit backends pick the right shift. unsigned char/unsigned short— C integer-promotion lifts these to signedint, but the value must stay in range (zero-extended on load). Checkunsigned char c = 200; (c * 2)== 400, and(unsigned char)xtruncation.- Mixed-width unsigned arithmetic —
unsigned long(tyUInt64) vsunsigned int(tyUInt32): result width/signedness, and whether the 32-bit-pair path on i386/arm32 keeps it unsigned (EmitBinop64 keys on tyUInt64 — verify both operands). - Unsigned comparison after a cast —
(unsigned int)signedVar < otherUnsigned, and the==/!=paths (currently sign-independent, but confirm under the new truncation). - Hex/octal literal suffix ranges —
0xFFFFFFFFu(tyUInt32),0x100000000u(tyUInt64),0xFFFFFFFFFFFFFFFFu/UL/ULL. The lexer consumesl/Lbut onlyu/Usets the unsigned flag; confirmUL/ULLwidth and that a bare large hex literal (no suffix) gets a sane type. - printf
%u/%x/%luwidths — does the variadic path format the right 32 vs 64 bits for an unsigned arg (vs the just-fixed value model)? - Unsigned modulo in array indexing / hashing — the hot real-world use (lua/sqlite). Re-run a hash-heavy snippet and diff.
for/whileloop bounds with an unsigned counter crossing 2^31 or wrapping at 0 (for (unsigned i = n; i-- > 0;)).
Method
- Author a
test/cunsigned_sweep_*.cmatrix (or one program with many checks -> exit-code / oracle-diff) covering the operators × {bit-31-set operand, wrap boundary, mixed width}. - Compile + run each on x86-64 (gcc oracle) and i386/arm32/aarch64/riscv32 (vs
x86-64 oracle / gcc). The exit-code-bitmask style of
cunsigned_int_arith_b121.clocalises which check fails on which target. - For every divergence: trace to frontend (type tagging / missing truncation) vs
backend (hardcoded signedness), fix gated so Pascal stays byte-identical, add
the repro as a permanent guard, wire into
make test+ cross.
Acceptance
- A committed unsigned-semantics sweep test (guarded in
make test+ all four cross targets) that passes on all five targets, matching gcc. - Every divergence found is fixed (or, if genuinely out of scope, filed as its own Track A ticket and linked here).
- Self-host byte-identical; cross + lua green.
Notes
- Landmines already mapped (reuse): CLexAll stores token SVal only for ident/string (use CAttrFlags for any new literal flag); 32-bit backends' scalar-ordinal paths are the usual signedness offenders; x86-64 needs explicit 32-bit truncation since it computes in 64-bit regs. See [[project_c_unsigned_int_32bit_done]].
- Related: the C cross-coverage umbrella [[feature-c-cross-target-feature-coverage]].
TRIAGE note (2026-06-30) — confirmed concrete gap
Probe found a real residual: C signed arithmetic right shift is wrong on x86-64 —
int s = -2; (s >> 1) returns a value != -1 (should be -1, arithmetic shift). So
this sweep is justified, not speculative; start here.
Resweep done (2026-07-02, pin — see bug-c-signed-arith-shift-right for the fix pin)
Fixed the confirmed gap first: [[bug-c-signed-arith-shift-right]] (v146, all 6 backends, each encoding assembler-verified). Then systematically worked through every OTHER hypothesis this ticket listed, verifying each against a real gcc oracle before concluding:
(0x80000000u >> 1) == 0x40000000u— logical shr of unsigned, unaffected by the signed-shift fix. Matches.unsigned char c = 200; c*2 == 400and(unsigned char)300 == 44— narrow-type promotion + truncation. Matches.unsigned short s = 60000; s+10000 == 70000. Matches.- Mixed-width
unsigned long * unsigned int. Matches. (unsigned int)negativeInt < unsignedVarcomparison after cast. Matches.- Bare (no-suffix) large hex literal
0x100000000typed wide enough to hold its value (assigned tolong, round-trips exactly). Matches. UL/ULLsuffix width (0xFFFFFFFFuasunsigned long,0x100000000ULLasunsigned long long). Matches.- Mixed signed/unsigned
+(usual arithmetic conversions). Matches. - Negative literal assigned to
unsigned intwraps to the expected value. Matches. - Unsigned modulo/hashing idiom (
h = h*31 + i, the lua/sqlite hot path). Matches. - Unsigned wraparound loop bound (
for (n = 3; n-- > 0;)runs exactly 3 times, not near-infinitely from a signed misread of the post-decrement compare). Matches.
Every hypothesis beyond the one already-fixed signed-shift gap already
matched gcc — no further code changes needed. All 13 checks folded into
a permanent regression test, test/cunsigned_semantics_sweep_b138.c,
wired into make test + all four cross suites (i386/arm32/aarch64/
riscv32), all green. Self-host unaffected (test-only addition).