← board

A cast to a pointer-to-pointer drops the implicit second deref

Repro — no class, no metaclass, 11 lines

program r;
{$mode delphi}
type
  TRec  = record a, b: Integer; end;
  PRec  = ^TRec;
  PPRec = ^PRec;
var r: TRec; p: PRec; pp: PPRec;
begin
  r.a := 11; r.b := 22; p := @r; pp := @p;
  WriteLn('cast  a=', PPRec(pp)^.a, ' b=', PPRec(pp)^.b);
  WriteLn('deref a=', pp^^.a,       ' b=', pp^^.b);
end.
PPRec(pp)^.a / .b pp^^.a / .b
fpc 3.2.2 11 / 22 11 / 22
HEAD 8c8fb55b6d26 4310376 / 0 11 / 22
pin v404 4306248 / 0 11 / 22

PPRec(pp)^ yields a PRec — a pointer. The .a selector then requires a second, implicit dereference. pxx emits only the explicit one and applies the field offset to the pointer value itself: .a is that value truncated to 4 bytes, .b is the 4 bytes past it.

What the two rows tell you together

The explicit pp^^.a spelling is correct, so the record identity resolves fine and AliasPtrBaseRec / the deref stamping are all doing their job. Only the CAST-headed spelling is short a level. That localises this to the address computation on the cast path, not to the alias table.

Order-independent and unchanged on the pin — so unlike the ticket this was split from, declaration order is not a factor here and there is nothing to bisect.

Do not reuse the class-ref probe

The shape was originally found as PPVMT(ppv)^.__ClassRef.Val(3), and that probe cannot fail: Val is a CLASS function, resolved off the static class type, so it runs and returns 42 without ever dereferencing the garbage class-reference. Every "works" row measured that way is uninformative. Use two distinct Integer fields, and read both.a sits at offset 0 and stays right even when the base is wrong, so a one-field probe is green while the bug is live.

Where it bites

generics.defaults.pas:1865 and fifteen sibling lines, through {$DEFINE EXTENDED_HASH_FACTORY := PPExtendedEqualityComparerVMT(Self)^.__ClassRef}. That is the cast spelling, so corpus rung 6a is blocked on this ticket rather than on the alias-repair fix that closed its former sibling.

RESOLVED 2026-09-06 — the SHARED walker had no implicit-deref arm

test/test_a_pointer_cast_dereferences_implicitly_for_a_selector.{pas,expected}, wired in the Makefile; .expected is fpc 3.2.2's own output byte for byte.

ONE ARM, TWO TICKETS. This and bug-p-an-implicit-deref-over-a-typed-pointer-cast-is-dropped are the depth-2 and depth-1 faces of the same absent step, and both are fixed by the same change. They were filed a day apart by two reporters, each saying explicitly that it was not the other.

The fix is that arm, in the shared walker's tkDot branch, plus the C4 cast loop's delegation guard widened to hand over a pointer-valued . at all.

THE TICKET'S OWN DISCRIMINATOR WAS DEAD BY THE TIME I MEASURED IT. This ticket says "the explicit pp^^.a spelling is correct ... only the CAST-headed spelling is short a level". When I ran the 2x2 — {cast, no cast} x {carets written} — the NON-cast pp^.a was short a level too. It went green under me during the session; measured before that, the cast was not the discriminator at all. Reasoning from the premise would have aimed the fix at the cast path.

AND I NAMED THE WRONG COMMIT FOR THAT GREEN — corrected 2026-09-06 by frankD, who MEASURED it where I had only inferred it. I wrote b7b9e309e, which is what had just landed when I looked. The arm that actually owns pp^.a is frankD's 61932d0ec in compiler/symtab.inc (ResolveNodeRec). Its control is stronger than testing at 61932d0ec^ would have been: at today's tip 72ad063f6b7b9e309e and everything later PRESENT — reverse-applying only that symtab.inc hunk rebuilds to a different binary and turns test_implicit_deref_through_a_pointer_to_pointer RED; restoring it rebuilds byte-for-byte back and green. So nothing else in the tree substitutes for it. A row changing state while you watch names a TIME, not a cause, and the commit that landed nearest is the one you will reach for. frankD also notes the symptom itself moved under me: without that arm today the failure is not a wrong value but "e": this value is still a POINTER here — my own f9476d579 refusal — so a reading taken across that transition sees two different observables and neither of them dates the fix.

WHAT COST THE MOST, and it is a scope error not a code one: I first widened the delegation guard, rebuilt, and nothing moved. The delegation was being taken. The arm I was delegating TO was in a different function 2100 lines away in the same file — I had read the arm at :2561 and the walker at :4683 in one session without noticing they are not the same routine. A file is not a scope, and "the shared walker has this arm" was a claim I had checked about the FILE.

A THIRD DEFECT IS FILED, NOT FOLDED IN, because its repair is a DIAGNOSTIC and not an address computation: bug-p-a-half-dereferenced-pointer-chain-answers-garbage-instead-of-refusingppp^.a on a three-deep pointer compiles and prints 4310376 where fpc says Illegal qualifier. Pre-existing on pin v404 and unchanged by this fix.

Controls: rows E..H are every spelling where the dereference is WRITTEN OUT. The change INSERTS a dereference, so what it can break is inserting a SECOND one where the caret already did the job, and only a row that already dereferences can catch that. Row B is a METHOD call — the walker reaches its method arm only once the receiver has become a record, so a fields-only fix prints every number correctly and still calls Sum with a pointer as Self.

Log