Technical Article

Delphi Cross-Compiler Build Matrix: HotXLS Since XE5

HotXLS ships one Object Pascal codebase to every Delphi and C++Builder release from XE5 forward, and build-All-Lib-TRIAL.cmd is the script that proves it: 43 build legs, covering 12 Delphi versions on Win32 and Win64 plus 10 C++Builder Win32 and 9 Win64 package builds. From v2.363 through v2.374 that script was never run to completion, and the XE5 leg was broken the entire time

Nothing about the failure was subtle once it was seen. Five distinct constructs that the current compiler accepts without comment are hard errors on RAD Studio XE5, which the build matrix labels 12.0. The v2.375.0 release fixed all five and the matrix went green again at 43 of 43. What follows is each rejection, why the old compiler is arguably right about the two it rejects on type grounds, and the more embarrassing part: the probe script written to diagnose the mess reported a false pass on its first run

Why did the XE5 leg rot without anyone noticing?

The XE5 leg rotted because day-to-day development ran only the 37.0 four-script set, and a green local build says nothing about a compiler you did not invoke. The full matrix is a separate, slow script that the trial installer calls before Inno Setup collects files, so it gets exercised at packaging time rather than at commit time. Twelve releases fit in that gap

The leg arithmetic is worth spelling out because it is where the coverage illusion lives. DELPHI_TRIAL_VERSIONS enumerates 12.0 through 37.0 and each of those 12 versions builds twice, Win32 and Win64. CB_TRIAL_WIN32_VERSIONS lists 10 versions, and CB_TRIAL_WIN64_VERSIONS only 9, because XE5 has a C++Builder package project but does not ship the Win64 package startup object c0pkg64.o. Twelve plus twelve plus ten plus nine is 43. Running four of them and calling the codebase portable is a category error, and it is the specific error that let this happen

HotXLS has been bitten by the same shape of problem from the opposite direction. A new unit that is reachable through a uses clause but absent from the .cbproj file list compiles perfectly under Delphi, because dcc implicitly pulls unlisted units into the package and at worst emits a W1033 hint. C++Builder emits an .obj only for units named in <DelphiCompile>, so the same code dies at the ilink stage with an unresolved external. One toolchain hides what the other catches. That is the whole argument for running the matrix rather than trusting a representative compiler

Hard type casts the old Win32 compilers reject

Two of the five rejections are the same bug wearing different clothes: a hard type cast applied to a floating-point expression rather than to a variable. On Win32 the older compilers evaluate arithmetic through the x87 stack, so an addition involving a Double is carried at 80-bit excess precision and its static type becomes the 10-byte Extended. Casting 10 bytes down to an 8-byte TDateTime is not a legal typecast, and the compiler says so with E2089 Invalid typecast

The maddening detail is that the variable form is fine. TDateTime(Serial) compiles on every version in the matrix, because Serial is already 8 bytes and the cast is size-preserving. Add anything to it and the expression widens underneath you. The fix is not a wider cast or a conditional define, it is to stop casting: an implicit real-to-real assignment converts correctly on every compiler HotXLS supports, and it says what the code actually means

// Rejected on XE5 (Win32): each addition is evaluated as a 10-byte
// Extended, and the 10-to-8 narrowing cast raises E2089
if Dates1904 then
  Value := TDateTime(Serial + XLSDate1904Offset)
else if Serial < 60 then
  Value := TDateTime(Serial + 1)
else
  Value := TDateTime(Serial);        // this one is accepted: no addition

// Version-safe: let the real-to-real assignment do the conversion
if Dates1904 then
  Value := Serial + XLSDate1904Offset
else if Serial < 60 then
  Value := Serial + 1
else
  Value := Serial;

// Same class of rejection in the cell value packer: a hard Double cast
// of an integer. Divide instead - the operator already yields a real
if (Scaled = intVal) and (Double(intVal) / 100 = AValue) then   // E2089
  ;
if (Scaled = intVal) and (intVal / 100 = AValue) then           // portable
  ;

The Serial < 60 branch is the 1900 leap-year fiction, not an off-by-one: serial 60 is Excel's non-existent 1900-02-29, so serials below it need the extra day before DecodeDate sees them. Portability work should never quietly change that kind of logic, which is exactly why the safe edit here removes the cast and leaves the arithmetic untouched

What breaks when nil is a procedural argument?

A bare nil passed where a procedural type is expected fails to bind during overload resolution on the older compilers. The call site in HotXLS is ResolveIndexedColor, which is overloaded and takes a TXLSTryResolveSystemColor callback that most callers do not need. Newer compilers resolve nil against the procedural parameter and pick the right overload. XE5 does not, and the diagnostic points at the overload set rather than at the argument, which is how you lose twenty minutes

The portable answer is to give the null callback a type. A unit-level variable of the procedural type is zero-initialized by the language, so it is already nil without an initializer, and it carries the type information the old resolver wants. Where a unit-level variable would be overkill, a typed local assigned nil does the same job

var
  // A nil procedural literal does not bind in the older compilers
  // overload resolution; a typed, zero-initialized variable does
  NilSystemColorResolver: TXLSTryResolveSystemColor;

// ...

FWorkbook.ResolveIndexedColor(AIndexedColor, xicsBiffIcv, ARole,
  NilSystemColorResolver, Resolution);

// The same fix with a typed local, in the XLSX workbook
function TXLSXWorkbook.ResolveIndexedColor(AIndex: Int64;
  ASpace: TXLSIndexedColorSpace;
  out AResolution: TXLSIndexedColorResolution): Boolean;
var
  NoResolver: TXLSTryResolveSystemColor;
begin
  NoResolver := nil;
  Result := ResolveIndexedColor(AIndex, ASpace, xicrGeneral, NoResolver,
    AResolution);
end;

Note that this is a real language-level difference and not a compiler bug worth working around with defines. The zero-initialized variable is correct on every version in the matrix and costs one line, so there is no conditional compilation here at all. Reach for {$IF CompilerVersion} only when the platform genuinely differs between releases, which is the case exactly once in this batch

Protected VCL methods move between releases

TPicture.LoadFromStream is public on current VCL and protected on the older versions HotXLS supports, so a direct call compiles now and fails then. HotXLS uses it to validate that a worksheet background image payload really decodes, a signature check that runs before the HTML exporter commits to embedding the bytes. The classic Pascal answer applies: declare a descendant in the same unit purely to widen the visibility, and cast through it at the call site

type
  // TPicture.LoadFromStream is protected on the older VCL versions the
  // library supports; a same-unit descendant exposes it
  TXlsxPictureAccess = class(TPicture);

// ...

Stream.WriteBuffer(AData[1], Length(AData));
Stream.Position := 0;
TXlsxPictureAccess(Picture).LoadFromStream(Stream);
Result := (Picture.Graphic <> nil) and not Picture.Graphic.Empty and
  (Picture.Graphic.Width > 0) and (Picture.Graphic.Height > 0);

The accessor-class trick is safe here because the descendant adds no fields and is never instantiated; the cast only changes what the compiler will let you name. It is still worth a comment at the declaration, because a reader who only ever builds on a current IDE will otherwise see a pointless type. Background image handling shows up again in the custom VCL grid rendering path, where the same decoded payload feeds the on-screen sheet

The GdiplusStartup token type changed twice

The only rejection in the batch that genuinely requires conditional compilation is the var parameter type of GdiplusStartup, which changed across VCL generations in a way that leaves no single spelling valid everywhere. Version-by-version probing pinned the actual behavior: the 12.0 through 20.0 legs accept only Cardinal, the 21.0 and 22.0 legs accept only THandle or ULONG_PTR, and 23.0 and 37.0 accept both. In release names, that is Cardinal from XE5 through 10.3 Rio and THandle from 10.4 Sydney on. Because the two accepting ranges do not overlap for 12.0 through 22.0, no unconditional declaration works: the guard keys on CompilerVersion >= 34, which is Sydney, and the call is fully qualified as Winapi.GDIPAPI.GdiplusStartup so that unit resolution order cannot substitute a different declaration on some version in the middle of the range

function TXLSPageImageExporter.EncodeTiff(Stream: TStream): Integer;
var
  StartupInput: TGdiplusStartupInput;
  // GDIPAPI's GdiplusStartup var-parameter type follows the VCL
  // generation: Cardinal through Rio, THandle from Sydney on
  {$IF CompilerVersion >= 34}
  StartupToken: THandle;
  {$ELSE}
  StartupToken: Cardinal;
  {$IFEND}
  TiffEncoder: TGUID;
begin
  FillChar(StartupInput, SizeOf(StartupInput), 0);
  StartupInput.GdiplusVersion := 1;
  CheckStatus(Winapi.GDIPAPI.GdiplusStartup(StartupToken, @StartupInput,
    nil), 'startup');
  if GetEncoderClsid('image/tiff', TiffEncoder) < 0 then
    raise EInvalidGraphic.Create('GDI+ TIFF encoder is unavailable');
  // ... encode ...
end;

This is the TIFF branch of the page image exporter, so the blast radius of getting it wrong is the whole raster export surface, including the paths described in exporting a cell range as a single image. Note also what the guard does not claim: ULONG_PTR and THandle are the same width on both platforms, so the choice is about which identifier the declaration names, not about 32-bit versus 64-bit correctness

Why did the first probe run report nothing?

The version probe reported nothing on its first run because res=$(...) assignments were being made inside a subshell, where they do not propagate to the parent. dcc32 exits 0 on success, so the exit code was the right signal to capture, and the script was capturing it into a variable that ceased to exist one line later. Every leg came back empty and the output looked like a probe that had not compiled anything, which is exactly what it was

The second failure was worse, because it produced a wrong answer rather than no answer. The probe classified a leg by counting lines matching Error, and Delphi does not prefix every fatal with that word. F1026 File not found is fatal and does not match, so a probe that could not resolve a unit at all was scored as a clean pass. XE5 does not ship Winapi.GDIPOPS.dcu, the first probe hit exactly that, and it went falsely green. The rule that came out of it is narrow and worth stating plainly: judge a compiler probe by the produced artifact or by the compiler's own summary line, never by grepping its output for a keyword. Grepping stderr for Error is a heuristic that fails in the one direction you cannot afford, silently reporting success

What supporting a decade of compilers actually costs

The honest accounting is that the code changes here are trivial and the process changes are not. Four of the five rejections were fixed by writing more ordinary Pascal, not by adding version machinery: drop a cast, divide instead of casting, give nil a type, declare an accessor class. Only GdiplusStartup earned an {$IF}. A codebase that spans XE5 to the current release does not become a thicket of conditional defines unless you let hard casts and newest-compiler idioms accumulate in the first place

What it really costs is build time and discipline. Forty-three legs is a slow script, which is precisely why it drifted to packaging time and then to never. The defensible middle ground is to keep the fast four-script loop for iteration and to run the full matrix on a schedule that cannot be skipped, because the failure mode is not a broken build you notice, it is a supported IDE that quietly stopped being supported twelve releases ago

That obligation is the flip side of shipping a native component at all. HotXLS reads and writes XLS, XLSX and ODS through Object Pascal only, with no Excel install and no COM dependency, which is what makes Office-free workbook automation possible on a locked-down server. The same property means the compiler is the whole platform contract, so every version in the matrix is a promise that has to be re-verified rather than assumed

The cross-compiler build matrix and the version-safe code discussed here ship as part of the HotXLS Delphi Spreadsheet Component, which supports Delphi and C++Builder from XE5 through the current release with prebuilt library binaries for every supported IDE