← board

A (double) cast of a float was a retag, not a conversion

Renamed. This was filed as "a float parameter and return are wrong in pure C on three targets" off a single failing row. Both halves of that title are false: the parameter is fine, the return is fine. frankA caught it first from its own control, and varying the shape confirmed it.

The eight-shape matrix that found it

Compiler 8a42f93ffe74, all against gcc. A float value reaching a double:

# shape x86-64 / aarch64 arm32 / riscv32 / i386
1 double a = (double)r; then print ok ok
2 double b = r; then print ok ok
3 as_int((double)r) — prototyped param ok ok
4 as_int(r) — prototyped, implicit ok ok
5 printf("%.2f", (double)r) ok 0.00
6 printf("%.2f", r) — implicit ok ok
7 printf("%.2f", (double)r * 2.0) ok ok
8 printf("%.2f", id_d((double)r)) ok ok

One shape out of eight. Everything else converts on the way past — a store, a prototyped parameter's conversion, an arithmetic operand — so the lie never surfaces. A variadic argument is the one consumer that trusts the node's tag.

Row 6 is the one that names the mechanism: the implicit form was always right, because default argument promotion sees a tySingle node and widens it. The explicit cast HID the single from that promotion — two paths to "this argument is a double", one of which lies.

Root cause

ParseCUnary's cast path routes float→float through AN_PTR_CAST with ASTIVal := -1, the "numeric / pointer reinterpret-retag", then stamps ASTTk[node] := Ord(castTk). No value is produced; only the label changes.

It survived because of the value model. x86-64 and aarch64 carry a single as double bits in a register, so relabelling it is genuinely free. On arm32, riscv32 and i386 a single occupies four raw bytes in its slot, and the variadic push then sends four bytes where the callee reads eight.

The narrowing direction was already correct — (float)someDouble round-trips through an anonymous tySingle temp, added by [[bug-c-cast-to-float-in-value-position-does-not-round-to-single]]. Only the widening mirror was missing, which is the shape devdocs/dev/normalise-dont-special-case.md warns about: fix one arm of a double case, grep for its sibling.

Fix

The mirror, in compiler/cparser.inc: (double)someSingle at castDepth = 0 round-trips through an anonymous tyDouble temp, so the assignment store performs the real widening on every backend. The variadic path then receives a genuine double and needs to know nothing about casts — stop it lying rather than teach every consumer to distrust the tag.

Verified

What this cost, and the cheap lesson

The control I wrote for the calling-convention ticket ran every value through variadic printf, so its float rows were reading THIS defect rather than the convention. frankA's control routed through a prototyped helper and did not see it. Neither control was lying — they exercised different conversions, and only one reached the broken shape. Two controls disagreeing is what located the mechanism; one control agreeing with itself would have buried it.

Log