Win64 Delphi code can fail where the same source runs cleanly on Win32, and the HotPDF Delphi PDF component hit five such cases during a recent hardening pass: Power(10, N) binding to the Single overload, a while loop reading a stale TList.Count, a High(Int64) bound that rounds up to 2^63, 15-digit float text on FPC, and test asserts that stop compiling
None of these show up if you only build and test Win32, which is exactly how they slipped in. The cases below come from HotPDF's SVG and XPS importers, its page renderer and its JSON job reader, and the numeric results quoted were reproduced with small probe programs built for Win32 and Win64. If you are moving a Delphi codebase to 64-bit, each one is worth a grep
Why does Power(10, 100) overflow only on Win64?
On Win64, System.Math.Power(10, N) with integer arguments resolves to the Single overload, so the result is computed and returned in single precision and anything above roughly 3.4E38 overflows. On Win32 the same call binds to the Extended overload and runs on the x87 FPU with 80-bit precision, so Power(10, 100) is simply 1E100
System.Math declares Power for Extended, Double and Single, plus a matching IntPower family that Power calls when the exponent is a whole number. On Win64, Extended is only an alias for Double (SizeOf(Extended) = 8), and for two integer arguments the compiler picks the Single version. The giveaway is precision, not just the overflow: on Win64, Power(10, 20) returns 1.0000000200408773E20, which is exactly Single(1E20). A Double result would print as 1E20. We saw the same binding with every Win64 compiler we tried, from Delphi 10.3 through compiler version 37.0
What happens next depends on the floating-point exception mask. Delphi 12 and later mask all floating-point exceptions by default, so the overflow is silent: Power(10, 100) returns +Inf and Power(10, -100) returns 0. Delphi 11 and earlier leave exOverflow unmasked, and the same call raises EOverflow. Applications that set the mask themselves, and DLLs loaded into such hosts, get whatever behaviour the host chose, which is why a library cannot assume either outcome
uses
System.SysUtils, System.Math;
procedure ShowPowerOverload;
var
N: Integer;
OldMask: TArithmeticExceptionMask;
begin
N := 20;
// Win32 prints 1E20; Win64 prints 1.0000000200408773E20 (Single overload)
Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));
// Reproduce what Delphi 11, or a host with strict FP settings, does
OldMask := GetExceptionMask;
SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
try
N := 100;
Writeln(Power(10, N)); // Win64: EOverflow; Win32: 1E100
finally
SetExceptionMask(OldMask);
end;
end;
Unmasking exOverflow and exInvalidOp for the duration of a test is the cheapest way to see what an older compiler or a strict host sees. On a modern compiler with default settings the bug does not crash, it produces infinities and zeros, and those are much harder to spot in a test log. Restore the previous mask in finally: the mask is per-thread state, and the rest of the test run inherits whatever you leave behind
How the overload reached HotPDF SVG and XPS import
HotPDF's SVG and XPS path readers share one number scanner, and that scanner scaled the mantissa with Power(10, Exponent) once it had read an exponent. Any SVG passed to THotPDF.ImportSVGFormXObject (the entry point behind importing SVG into PDF as reusable form XObjects), and any path geometry handled during XPS and OpenXPS to PDF conversion, could therefore feed a coordinate such as 1e100 or 5e99 into that call
v2.770.91 had already capped the exponent at 100 and rejected values that would pass 1E300, which looked like enough: 1E100 is nowhere near the Double limit of about 1.8E308. On Win64 it still overflowed, because the computation never happened in Double at all. Since v2.770.155 the scanner builds the power of ten itself, and numbers such as 1e-100, or a long mantissa with a large negative exponent, read as their real value instead of collapsing to 0
A safe power of ten for bounded exponents
When the exponent is bounded, the safest power of ten is one you build yourself with Double multiplication. A loop of at most 100 multiplications costs nothing next to scanning the text around it, it never produces an intermediate larger than the final scale, and it behaves identically on Win32, Win64 and Free Pascal
const
MaxDecimalExponent = 100;
function TryScaleByPowerOf10(const Value: Double; Exponent: Integer;
out Scaled: Double): Boolean;
var
Scale: Double;
I: Integer;
begin
Scaled := 0;
Result := False;
if (Exponent < -MaxDecimalExponent) or (Exponent > MaxDecimalExponent) then
Exit;
// Refuse results that would leave the Double range
if (Exponent > 0) and (Value <> 0) and
(Log10(Abs(Value)) + Exponent > 300) then
Exit;
Scale := 1.0;
for I := 1 to Abs(Exponent) do
Scale := Scale * 10.0; // never exceeds 1E100
if Exponent >= 0 then
Scaled := Value * Scale
else
Scaled := Value / Scale; // divide: 1E-100 has no exact Double
Result := True;
end;
Three details carry the weight. The range check uses two comparisons rather than Abs(Exponent) <= 100, because Abs(Low(Integer)) is still negative and would sail straight through. Negative exponents divide by the scale instead of multiplying by a precomputed 1E-100, which has no exact Double and would add one more rounding step. And the Log10 pre-check refuses results outside the Double range before the multiplication has a chance to overflow
Be clear about what the loop gives up. Powers of ten up to 1E22 are exact in Double; past that every multiplication rounds, and after 100 of them the scale sits a few units in the last place away from the correctly rounded 1E100. For drawing coordinates that is invisible. For a general-purpose text-to-double conversion that must reproduce every value bit for bit, it is not good enough, and you need a correctly rounded conversion algorithm instead
When dcc64 reads a stale TList.Count in a while loop
We observed the Win64 compiler (dcc64, compiler version 37.0) generate code for a while List.Count > Start do loop that deleted from the end of the list and compared against a stack temporary instead of re-reading Count. The rewrite that fixed it was a for ... downto loop, whose bounds are evaluated exactly once by definition
The loop arrived in v2.769.3, which taught the renderer's transparency-group code to keep soft masks created inside a group alive across a two-pass render and free them afterwards. The cleanup sat in a finally block after a one- or two-pass for loop, inside the per-tile loop. Reduced to its shape, the before and after look like this:
// The shape we saw miscompiled by dcc64 (compiler version 37.0)
procedure DropMasksWhile(Masks: TList; Start: Integer);
begin
while Masks.Count > Start do
begin
TObject(Masks[Masks.Count - 1]).Free;
Masks.Delete(Masks.Count - 1);
end;
end;
// Replacement: the bounds are evaluated once, no temporary to go stale
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
Idx: NativeInt; // TList.Count is NativeInt since Delphi 12
begin
for Idx := Masks.Count - 1 downto Start do
begin
TObject(Masks[Idx]).Free;
Masks.Delete(Idx);
end;
end;
In the generated Win64 code, the Count in the loop condition and the Count read inside the body shared one stack slot. The condition compared against that slot on entry, before anything had written it, and nothing refreshed it after Delete. When a group had created no soft masks of its own, the body ran anyway and asked an empty list for item -1, so in 64-bit builds every page containing such a transparency group failed with EListError. The Win32 code for the same source was correct, and v2.770.1 replaced the loop
We have not reduced this to a minimal reproduction, and a small standalone loop like DropMasksWhile may well compile correctly; the surrounding try/finally and nested loops appear to matter. Treat it as code generation we observed on one compiler version, not as a known defect of every Win64 compiler. The practical lesson is cheaper than the root cause: a loop whose condition re-reads a collection's count while the body shrinks that collection is worth rewriting as a fixed-bound for ... downto, and renderer changes need a full Win64 test run, not just Win32
Locating a crash that only an optimised Win64 build shows
The failure only reproduced in the optimised Win64 build, so the location came from tools outside the IDE. A small probe program registered a vectored exception handler with AddVectoredExceptionHandler, captured the stack on the first exception with RtlCaptureStackBackTrace, and translated the return addresses into function names using the detailed map file the linker writes with -GD. Disassembling that function then showed the compare reading a stack slot, [rbp+0x298], that was only ever written inside the loop body. That is the level of evidence you want before you blame a compiler, and it took less time than stepping through a release build
Why is High(Int64) not a safe upper bound for a Double?
A Double cannot represent High(Int64): converting 9223372036854775807 to Double rounds up to exactly 2^63, one past the largest Int64. On Win64 that conversion happens inside the comparison itself, so D <= High(Int64) is True for D = 2^63, and the Round or Trunc that follows overflows
Win32 hides this for the same reason it hid the Power problem. The comparison runs in 80-bit Extended precision with a 64-bit mantissa, where High(Int64) is exact and 2^63 correctly compares greater. Win64 has no wider type to fall back on. The out-of-range conversion is not pretty either: in our Win64 tests Round(2^63) returned Low(Int64), a silent sign flip, whether exInvalidOp was masked or not. Win32 returns the same value when masked and raises EInvalidOp when unmasked
| Expression | Win32 | Win64 |
|---|---|---|
Power(10, N), N = 20 | 1E20 | 1.0000000200408773E20 |
Power(10, 100), exceptions masked (Delphi 12+ default) | 1E100 | +Inf |
Power(10, 100), exOverflow unmasked | 1E100 | EOverflow |
D <= High(Int64), D = 2^63 | False | True |
Round(2^63), exInvalidOp unmasked | EInvalidOp | Low(Int64) |
HotPDF met this in the JSON reader behind its document job values. JSON puts no range limit on numbers, and the old serialiser turned any value with Frac(Value) = 0 into an integer with Round, so a perfectly legal 1e19 became either a wrong integer or an exception, depending on the mask. Since v2.770.169 a whole number is written as an integer only when it fits in Int64, everything else keeps its floating-point text, and the integer getters return the caller's default for out-of-range values instead of a wrapped one
const
TwoPow63 = 9223372036854775808.0; // 2^63, exact in Double and Extended
function TryDoubleToInt64(const Value: Double; out R: Int64): Boolean;
begin
R := 0;
Result := not IsNan(Value) and not IsInfinite(Value) and
(Frac(Value) = 0) and (Value >= -TwoPow63) and (Value < TwoPow63);
if Result then
R := Trunc(Value);
end;
function JsonNumberText(const Value: Double): string;
var
R: Int64;
begin
// Callers reject NaN and infinities first: JSON has no spelling for them
if TryDoubleToInt64(Value, R) then
Result := IntToStr(R)
else
begin
{$IFDEF FPC}
Str(Value:24, Result); // FPC Win64 ffGeneral stops at 15 digits
Result := Trim(Result);
{$ELSE}
Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
end;
end;
The upper bound is the literal 9223372036854775808.0 with a strict <. That constant is 2^63, exact in both Double and Extended, so the comparison means the same thing on every platform. The lower bound can use >= because -2^63 is exactly Low(Int64). Testing IsNan and IsInfinite first, with short-circuit evaluation, keeps NaN and infinities away from Frac and the comparisons, which can raise EInvalidOp when the host has unmasked it
How many digits does float-to-text really give you on Win64?
Fewer than you ask for, on two compilers out of three. Free Pascal 3.3.1's FloatToStrF(Value, ffGeneral, 17, 0) on Win64 stops at 15 significant digits, so 1/3 comes back as 0.333333333333333 and two different Double values can serialise to identical text. Str(Value:24, Text) followed by Trim produces 17 significant digits in scientific notation, 3.3333333333333331E-001 for the same value, and always writes a period as the decimal separator regardless of locale. If HotPDF on FPC is part of your build matrix, the HotPDF Free Pascal and Lazarus Win64 support notes cover the rest of the platform differences
Delphi accepts the 17-digit request, but the two Delphi targets still disagree on output: FloatToStrF(0.1, ffGeneral, 17, 0) gives 0.10000000000000001 on Win32 and 0.1 on Win64. The Win64 RTL can also introduce a last-digit rounding error both when formatting and when parsing, so more digits narrow the gap without guaranteeing that every Double bit pattern survives a text round trip. HotPDF's documentation makes no such promise, and yours should not either unless you ship a correctly rounded formatter and parser of your own. Pass TFormatSettings.Invariant, or replace the separator yourself on older Delphi versions, so a German or French locale does not write a comma into JSON
Why does Assert.AreEqual stop compiling on Win64?
Assert.AreEqual(3, Length(Arr)) on a dynamic array compiles for Win32 and fails for Win64 with E2532, "Couldn't infer generic type argument from different argument types", because Length of a dynamic array returns NativeInt on Win64. With an Integer literal on one side and a 64-bit NativeInt on the other, DUnitX's generic Assert.AreEqual<T> cannot settle on a single T, and the build stops
TList.Count triggers the same error since Delphi 12, where the property became NativeInt; Delphi 11 still declares it as Integer. Length of a string returns Integer on both platforms and is unaffected, which is why the error appears in some test units and not others. Write the type argument explicitly, Assert.AreEqual<NativeInt>(3, Length(Arr)), and compile the test project with dcc64 before committing. A suite that only ever builds for Win32 will not tell you its Win64 build is broken until somebody else tries it
Win64 porting checklist for Delphi numeric code
- Search for
Power(andIntPower(calls with integer arguments; passDouble-typed values or build bounded powers of ten yourself - Run numeric tests at least once with
exOverflowandexInvalidOpremoved throughSetExceptionMask, on both Win32 and Win64 - Write the
Int64upper bound as< 9223372036854775808.0, never<= High(Int64), and reject NaN and infinities before any comparison - Do not convert a parsed number to
Int64just becauseFracis 0; JSON numbers can be far larger - Rewrite
whileloops that re-readCountwhile deleting items as fixed-boundfor ... downtoloops - On FPC Win64, use
Str(Value:24, Text)when you need more than 15 significant digits - Use
Assert.AreEqual<NativeInt>forLengthandCountasserts, and compile tests with dcc64 before committing - After any change to a parser or renderer, run the full regression suite on Win32 and Win64, not just one of them
The library-side fixes described here are all in HotPDF since v2.770.169, so SVG import, XPS conversion, transparency rendering and JSON job handling now behave the same on Win64 as on Win32. If you generate or process PDF files from Delphi or C++Builder for both platforms, the HotPDF Delphi PDF component page has the downloads and the full feature list