← board

A Single in array of const is boxed as 4 bytes and read as 8

Measured — pxx vs FPC 3.2.2

var s: Single;
begin
  s := 0.1;
  WriteLn(Format('%g',   [s]));
  WriteLn(Format('%.4f', [s]));
  WriteLn(Format('%e',   [s]));
end.
pxx FPC
%g 5.122630465115234E-315 0.10000000149011612
%.4f 0.0000 0.1000
%e 5.1226304651152340E-315 1.0000000149011612E-001

A Double argument is correct on every one of these, so it is the Single path alone.

Cause — confirmed by arithmetic, not by reading

compiler/ir.inc (~5088), lowering array of const:

else if (vrElemTk = tySingle) or (vrElemTk = tyDouble) or (vrElemTk = tyExtended) then
  vrTag := 3             { vtExtended (boxed) }
...
vrBoxSym := AllocVar('', vrElemTk);      { <-- box is the ELEMENT's width }

The tag says vtExtended, and every consumer honours that — lib/rtl/sysutils's FmtArgFloat/FmtArgStr/FmtArgInt all do PDoubleRec(v.VExtended)^, an 8-byte read. But AllocVar('', tySingle) gives a 4-byte box. So the reader takes the Single's 4 bytes plus 4 bytes of whatever sits next to it.

Verified numerically rather than assumed:

Single(0.1) bits                        = 0x3DCCCCCD
those 32 bits as the low half of a double = 5.122630465e-315
pxx printed                               = 5.122630465115234E-315   ← identical

The neighbouring 4 bytes happened to be zero here, which is why the result is a stable denormal rather than obvious noise — and why this can look reproducible and harmless while being whatever is adjacent on the stack.

The fix

Widen at the box, not at the reader: when vrElemTk = tySingle, allocate the box as tyDouble and store the widened value. FPC does the same thing — it has no vtSingle at all, every float goes in as VExtended — so the tag stays right and no consumer changes.

Fixing it in the RTL instead is not possible: vtExtended is the only float tag there is, so the reader cannot tell a 4-byte box from an 8-byte one. (FmtArgIs32 recovers the original width for integers, but only because vtInteger and vtInt64 are distinct tags. Floats have no such pair, by FPC's design.)

Sweep before closing

Gate

The table above matches FPC, plus a Single through %d-style integer conversions and through a non-Format consumer. make test + self-host fixedpoint, and cross.

Resolution

The ticket's diagnosis was right in full, including the arithmetic. Fixed where it said: compiler/ir.inc, at the box, not at the reader.

vrBoxTk := vrElemTk;
if vrBoxTk = tySingle then vrBoxTk := tyDouble;
vrBoxSym := AllocVar('', vrBoxTk);

...and the IR_STORE_MEM carries Ord(vrBoxTk) too, so the store's DEST type performs the widening — the same mechanism the 32-bit C vararg promotion a few thousand lines up already relies on ("the store-to-double slot keeps the value double-wide"). No consumer changed, and the vtExtended tag is now honest.

Measured against FPC 3.2.2

before after FPC
%g 5.122630465115234E-315 0.10000000149011612 0.10000000149011612
%.4f 0.0000 0.1000 0.1000
%e 5.1226304651152340E-315 1.0000000149011612E-001 1.0000000149011612E-001

The sweep the ticket asked for

One divergence, pre-existing and NOT touched

Format('%d', [aSingle]) prints 3 here; FPC raises EConvertError: Invalid argument index. That is a laxness in FmtArgInt, not this bug — before the fix the same line printed a garbage integer, so the fix strictly improved it. Out of scope for a boxing ticket; it belongs to the sysutils Format compat surface if anyone wants FPC's refusal.

Tests

Gate: gate.sh quick GREEN (self-host fixedpoint + --tier quick + FPC seed canary), plus the four cross targets run under qemu above. compiler/builtin/** untouched, so no re-pin.

Method note

The first cross-target comparison looked like all four targets DIFFERED. They did not: building the repro with FPC in the scratch directory had overwritten the pxx binary of the same name, so "native" was FPC's build raising its EConvertError. Name the oracle's binary differently — the provenance rule applies to the reference build too, not only to the compiler under test.

Log