wasm32 picks signed division for an unsigned 64-bit value
Found 2026-09-19 (frankB) while closing [[bug-c-hosted-c-on-wasm32-needs-environ-and-va-arg-so-stdio-programs-still-refuse]] — the first hosted C program that could actually print on wasm32 printed its integers wrong, and only above 2^63.
Measured
compiler/pascal26 at c6c87fa69d8c, test/c_wasm32_hosted_stdio.c diffed
whole against the gcc oracle:
| value | gcc / six targets | wasm32 before |
|---|---|---|
%llu of 2^64−1 |
18446744073709551615 |
/ |
%llu of 2^63−1 |
9223372036854775807 |
correct |
%d, %u, %x, floats |
match | match |
The row below 2^63 was correct the whole time, which is the reason nothing caught this earlier: a wrong SIGNED answer on non-negative data equals the right answer until the top bit is set. There is no wasm32 C test corpus row that prints an integer above 2^63.
Cause
WasmBinopWidth in compiler/ir_codegen_wasm32.inc decided one sgn for the
whole operation:
sgn := TypeSigned(tkA) or TypeSigned(tkB);
__crtl_utoa divides by its base:
d = v % (unsigned long)base; v = v / (unsigned long)base;
The cast lowers to a mask against a tyInt64 constant, so tkB is signed.
v is unsigned long long. Signed-if-either then selects i64.rem_s for a
zero-extended value whose top bit is set — the remainder comes out negative,
'0' + d walks below '0', and the loop emits one character and stops.
wasm2wat over a hosted module: zero occurrences of i64.div_u or
i64.rem_u, which is the shape of the bug rather than a symptom of it — the
unsigned opcodes were unreachable by construction, not merely unused.
The repair that was wrong, and why it is worth keeping in the record
First attempt: at the operation's width, an UNSIGNED operand wins.
if (not TypeSigned(tkA)) and (TypeSlotSize(tkA) = wantSz) then sgn := False;
%llu went correct. test/c_integer_signedness.c row 8 —
n / (long long)q, an explicit cast TO signed — went unsigned: measured
wasm32 = 124, six targets = 116. The cast node still carries the source
expression's unsigned type, so the operand check reads the wrong thing.
and in place of or passes both of those and is wrong for a third: a narrow
unsigned (unsigned char, unsigned short) promotes to signed int in C, so
-7 / (int)w must be signed and and makes it unsigned — four billion.
No rule over the operands can be right, because the frontend has already applied C's conversions and recorded the answer. Re-deriving one is a second opinion that can only disagree.
The fix
Let the node's own recorded type decide, at the operation's width:
tkN := IntToTypeKind(IRTk[node]);
if (not WasmIsCompare(WasmBinopOp(node)))
and (TypeIsOrdinal(tkN) or TypeIsPointerSized(tkN))
and (TypeSlotSize(tkN) = wantSz) then
sgn := TypeSigned(tkN);
The compare exclusion is load-bearing and is not defensive coding. A
compare's recorded IRTk is tyBoolean; TypeSigned(tyBoolean) is False, so
the same fix without that clause turns every signed <, <=, >, >= into
its unsigned opcode. That was caught by row 256 of the subject, which exists
for exactly this and asserts a comparison of two signed values, not a division.
Guard
tools/c_int_signedness_every_target.sh — thirteen shapes over x86_64, i386,
arm32, riscv32, aarch64, xtensa and wasm32, gcc as the oracle, the whole
printed line diffed so no expected value is written down twice. No
admitted-refusal branch: every target builds plain integer arithmetic plus one
printf today, so a target that stops is a regression.
The subject prints its failure mask rather than returning it. The first
draft returned bad ? 100000 + bad : 42, and 100000 + 138 is 42 mod 256 —
rows 2, 8 and 128 failing together would have exited with the all-pass answer.
The rule under it
Where a frontend has already applied a language's conversion rules and
recorded the result type, ask the IR. Re-deriving the same property from the
operands is a second opinion that can only disagree — it is right wherever
it agrees and silently wrong wherever it does not, and the disagreement shows
up on the values nobody tests. Banked in devdocs/dev/debugging-playbook.md
("ASK THE IR WHAT THE FRONTEND RECORDED"). Not promoted to CLAUDE.md: one
subsystem. The promotion test is a second independent one, and frankB and
frankuser agreed on 2026-09-19 to hold it in the playbook until that arrives
rather than argue it up on how good it reads.