← board

Measured, 2026-08-26, pinned compiler

var a: Variant; l: Int64;
begin
  a := 5000000000;  WriteLn(a);   { x86-64 5000000000   i386/arm32 705032704 }
  l := 5000000000;
  a := l;           WriteLn(a);   { x86-64 5000000000   i386/arm32 705032704 }
  a := 3000000000; a := a * 2;
  WriteLn(a);                     { 6000000000 on all four -- see below }
end.
step x86-64 / aarch64 i386 / arm32
v := 5000000000 then write 5000000000 705032704
v := l (l: Int64) then write 5000000000 705032704
v := l with l = -12, then v or 5 -11 4294967285
a COMPUTED wide result (v := v * 2) 6000000000 6000000000

The last row is the tell: a result produced by the variant runtime is stored with a full 8-byte payload and survives, so the slot itself is 16 bytes and the payload really is an Int64 on every target. Only the BOXING path narrows.

Root cause

EmitVariantFill386 (compiler/ir_codegen386.inc) is documented as taking "8 bytes for a double (spilled xmm0), else 4 bytes (eax pushed)". It does pop ecx; mov [eax+8], ecx and then fills [eax+12] — with cdq's sign for VT_INT, with ZERO for every other tag. There is no path by which the high half of an Int64 operand reaches the payload at all. arm32's twin has the same shape.

That is the i386 "fat slot" model working as designed for scalars (IREmit386CheckScalarSym: "the parser still lays out 8-byte slots and i386 code reads/writes only the low 4 bytes"). A Variant payload is the case where the high half IS read, so the model's assumption does not hold.

Note the two tags behave differently and BOTH are wrong for a wide value: VT_INT sign-extends (right for a small negative, wrong above 2^31) and VT_INT64 zero-fills (wrong for every negative). a := -12 happens to work only because the literal boxes as VT_INT.

The fix

The boxing path must move the whole payload on the 32-bit targets: push the Int64 operand as a PAIR (the backends already have EmitBinop64_386's edx:eax convention for exactly this) and store both dwords, the way the double arm already spills 8 bytes. Then the VT_INT sign-extension arm becomes a narrow-operand-only case rather than the general one.

root-cause-over-microfix.md: vary the shape first — check var parameters, a function result assigned to a Variant, and a record field, not just a plain assignment.

Acceptance

Outcome — 2026-08-26

Fixed on both 32-bit targets, and the fix is one rule rather than two patches: the high word of a Variant payload comes from the payload's TYPE, not from the tag.

What was wrong

EmitVariantFill386 and EmitVariantFillArm32 received a 4-byte payload for everything that was not a double, and then filled [slot+12] by asking whether the TAG was VT_INT — sign-extend if so, zero otherwise. Three cases, two of them wrong:

payload old high word right answer
Integer, Int8/16/32 (VT_INT) sign of the low dword correct by luck — it IS the sign
Int64 / UInt64 (VT_INT64) zero the value's own high dword, which was never pushed
NativeInt on a 32-bit target (VT_INT64) zero the sign of the low dword
Boolean, Char, Cardinal, pointer zero correct

tyNativeInt is the row that shows the tag was the wrong question all along: it always FITS in 32 bits there, so nothing was truncated, and it still came out wrong (v := n with n = -12 gave 4294967284) purely because it shares a tag with Int64.

The fix

Is64Bit386 needed a forward at the top of ir_codegen386.inc: it is defined below EmitVariantFill386, pxx resolves it anyway and the FPC SEED does not. The gate caught it and named the precedent (bug-a-fpc-seed-drift-emitasmx64-forward) in the failure text.

Verification

Thirteen boxing rows and eight shape rows, run on x86-64, i386, arm32 and aarch64: all four now produce identical output, and it matches fpc 3.2.2 -Mobjfpc -O1.

The shapes were varied before closing, as root-cause-over-microfix.md asks: a plain assignment (literal and variable, positive and negative, above and below 2^32), a function result, a var parameter, a value parameter, a record field, an array element, a round trip back out to Int64, arithmetic on the boxed value, and a comparison. tyNativeInt and Cardinal cover the two edges of the signed/unsigned split.

Every other Variant test in test/ was cross-run on i386, arm32 and aarch64 to check for collateral: all unchanged.

tools/gate.sh quick GREEN; self-host converged after 1 round.

Found on the way, not fixed here

RoundTripParam := d — assigning a Variant to the function result under the function-name spelling — does not convert at all; it stores the variant slot's ADDRESS. Result := d, the same line in the other spelling, converts correctly. Wrong on EVERY target including x86-64, for Int64, Double and AnsiString results alike, and measured against fpc 3.2.2, which gets all six rows right. Filed as [[bug-p-a-variant-assigned-to-the-result-by-function-name-is-not-converted]]; the new test routes through a local and says why.

Also seen and left alone: test_variant_record_tag_padding's nested-overlay row fails on i386 and arm32 (13/14 there, 14/14 on aarch64). That is a record VARIANT-PART overlay, a different mechanism from the Variant TYPE payload this ticket is about, and it fails identically before and after this change.

Not in scope

riscv32: it refuses var_store in IR codegen outright, so it has no Variant support to truncate. xtensa likewise.

Files

Log