← board

Measured, 2026-08-25

type
  TIn = record x, y: Integer; end;
  TOut = record a: Integer; inner: TIn; end;
  POut = ^TOut;  PPOut = ^POut;
var r: TOut; p: POut; pp: PPOut;
begin
  r.a := 1; r.inner.x := 5; r.inner.y := 9;
  p := @r; pp := @p;
  WriteLn(pp^^.inner.y);          { a }
  WriteLn(PPOut(pp)^^.inner.y);   { b }
  WriteLn(POut(p)^.inner.y);      { c }
end.
fpc 3.2.2 pxx before
a — no cast 9 9
b — cast + two derefs 9 1 (= r.a)
c — cast + one deref 9 9

Row b is the whole bug and it is the dangerous kind: it compiles, it runs, it exits 0, and it answers the field at offset 0. A metaclass at the end of such a chain went the same way — PPVmt(pp)^^.ClassRef.Name printed the class-ref POINTER instead of calling the method, because NodeMetaclassCi's FIELD arm asks ResolveNodeRec too.

Root cause — a FOURTH copy of the deref walk

ParseLValueAST has always resolved a ^ through ResolveDerefShape, which returns the whole triple (remaining depth, ultimate base kind, ultimate base record) and stamps it on the deref node. The pointer-alias cast arm in ParseFactorCore (compiler/pasparser_expr.inc) had its own walk instead, asking NodePtrElem — a function that by contract knows only the immediate pointee and nothing about depth. One ^ was therefore fine and two were not.

Exactly the shape of the note already sitting in that arm (bug-p-a-second-deref-on-a-typecast-pointer-field-is-dropped), one level deeper: the same construct, two spellings, and the spelling with the cast is the broken one. devdocs/dev/normalise-dont-special-case.md is the doctrine; this was the fourth private notion of what a ^ yields.

Fix (landed 2026-08-25)

The cast's suffix walk now calls ResolveDerefShape and stamps ASTSOffset/ASTSLen/ASTIVal exactly as ParseLValueAST does. The adapter casts (ival -1/-2, the PChar/^Char reinterprets) carry no alias row for the resolver to read, so they keep the old alias-derived answer — detected by the resolver yielding no shape at all rather than by a list of node kinds.

Regression test test/test_pointer_to_a_pointer_through_a_cast_and_a_forward.pas (shared with [[bug-p-a-forward-declared-pointer-to-a-pointer-loses-a-level]]), .expected from fpc 3.2.2.

Where it was found

[[feature-pascal-corpus-generics]] — PPExtendedEqualityComparerVMT(Self)^.__ClassRef, spliced into ~30 call sites of Generics.Defaults by a {$DEFINE}.

Log