← board

IntToStr of a QWord prints it signed

Symptom

var u64: QWord;
begin
  u64 := QWord($8000000000000000);
  WriteLn('a writeln  : ', u64);            { 9223372036854775808  — correct }
  WriteLn('b inttostr : ', IntToStr(u64));  { -9223372036854775808 — wrong   }
  u64 := QWord($FFFFFFFFFFFFFFFF);
  WriteLn('c writeln  : ', u64);            { 18446744073709551615 — correct }
  WriteLn('d inttostr : ', IntToStr(u64));  { -1                   — wrong   }
end.

FPC prints the unsigned value in all four rows. Cardinal and Word are fine in pxx — they widen into Int64 losslessly — so only QWord at or above 2^63 is affected, which is exactly the range a hash, a checksum, a bitmask or a 64-bit id lives in.

The WriteLn(u64) row being right is what makes this nasty: the compiler's own formatting knows the type, the library function does not, so one program prints the same variable two different ways and neither call looks suspicious.

Root cause

sysutils.pas declares one IntToStr(value: Int64). A QWord argument is accepted by it and reinterpreted as signed. There is no IntToStr(QWord) overload and no UIntToStr at all (UIntToStr(u64) is a compile error: undefined variable (UIntToStr)).

Not a compiler gap. Overload resolution on QWord vs Int64 was verified to work on 2026-08-21:

function F(v: Int64): AnsiString; overload; begin Result := 'i64'; end;
function F(v: QWord): AnsiString; overload; begin Result := 'qw';  end;
{ F(q) -> 'qw', F(i) -> 'i64' on both fpc and pxx }

So adding the overload is sufficient; nothing in Track A needs to change.

Fix

Two functions in lib/rtl/sysutils.pas:

Both are the ordinary unsigned decimal loop; the existing IntToStr(Int64) body is the model minus the sign handling. Watch the digit loop: q div 10 on a QWord must be the unsigned divide (pxx picks that from the operand type — u64 div 2 was verified correct in the same run).

Gate

make lib-test green, plus the program above matching fpc 3.2.2 on all four rows and High(QWord)/High(Cardinal)/High(Word) round-tripping.

Note for whoever takes it

High(QWord) is itself refused by the compiler today (error: undefined variable (QWord) inside High(...)), which is a separate Track A gap — file it if you need it for the test, or write the literal.

Log

Resolution (2026-08-27, frankB)

Fixed as one of a three-ticket cluster with [[bug-b-inttostr-of-a-qword-above-2-63-renders-negative]] and [[bug-b-format-percent-u-prints-a-signed-value]] — one missing mechanism, three symptoms. lib/rtl/sysutils.pas had no unsigned decimal renderer at all: it parsed unsigned (StrToQWord/StrToQWordDef/TryStrToQWord all present) and printed signed, through the single IntToStr(Int64).

Rather than teach each caller to handle signedness, ONE unsigned digit loop was added and everything routes through it (devdocs/dev/normalise-dont-special-case.md):

High(QWord) compiles fine now, so the ticket's closing note about it being a Track A gap is stale — the test uses it.

Regression: test/lib_qword_render.pas + .expected, wired into make lib-test. 29 rows, byte-identical to fpc 3.2.2, and it deliberately keeps the rows that were already RIGHT (WriteLn, Str, IntToHex, %x) as controls — the value was never wrong, only its rendering.

Gate: make lib-test green, make demos 35/35 (both against stable v388).

Not touched, and deliberately: Format('%d', [q]) of a QWord prints signed in FPC too, and IntToStr(Cardinal) keeps picking the Int64 arm — both are asserted in the test as fpc's own answers. The sibling one lane up, bug-p-qword-div-by-a-literal-above-2-63-is-signed, is a compiler bug in NormalizeUnsignedLiteralOperand and stays Track A/P's.