← board

The declaration and the body were two different routines

The repro, 20 lines, and every line of it is load-bearing

program m;
type
  PItem = ^TItem;                  { alias ABOVE its record -- trigger 1 }
  TItem = record Data: Pointer; end;
  TL = class(TObject)              { same type section -- trigger 2 }
  public
    FC: Integer;
    procedure Rehash;
    procedure Cb(Item: PItem);
    procedure Go;
  end;
procedure TL.Rehash; begin Cb(nil); end;   { call BEFORE the body -- trigger 3 }
procedure TL.Cb(Item: PItem); begin FC := 7; end;
procedure TL.Go; begin Rehash; WriteLn('fc=', FC); end;
var l: TL;
begin l := TL.Create; l.Go; end.

pascal26:2: error: unresolved forward: TL.Cb, in: ./compiler/builtin/builtinheap.pas. fpc 3.2.2 prints fc=7.

Remove any one of the three triggers and it compiles:

variant result why
TItem above PItem compiles the alias is never a forward
class in a SECOND type section compiles ResolvePendingPointerAliases has already run
Cb's body written before Rehash's compiles the call binds to the body's proc, which has an address
free procedures with forward; compiles that path matches by NAME, not by proc identity

That table is the diagnosis: nothing about the pointer, everything about when the alias was read.

What the instrument said, rather than what the shapes suggested

A temporary print in FindProcOverloadRec, on the candidate it rejected:

PROBE find TL.Cb n=2 cand=146 match=FALSE
   p0 tk=6/6   rec=0/0  ptrelem=0/0  alias=-1/-1
   p1 tk=17/17 rec=0/0  ptrelem=5/1  alias=-1/-1

ptrelem=5/1 is the whole bug: the implementation says tyRecord, the declaration says tyInteger. Everything else on both params is identical, ProcParamAliasIdx included — so the alias-identity arm could never have separated them and the typed-pointer arm did.

tyInteger is not a type anyone wrote. It is the placeholder ParseTypeKind leaves when a ^T names a type that does not exist yet (RecordPendingPtrTarget(lo); Result := tyInteger).

The fix, and why it is at ParamPtrElemOf and not one layer up

symtab.inc, ParamPtrElemOf — the single funnel every parameter registration path reads, declaration and implementation alike:

else if (LastTypePointerAlias >= 0) and
        (AliasElemRec[LastTypePointerAlias] = REC_NONE) and
        (AliasTargetNLen[LastTypePointerAlias] > 0) then
  ParamPtrElemOf := Ord(tyUnknown)

The guard is exactly ResolvePendingPointerAliases's own row predicate, so the two agree by construction about what "still pending" means.

tyUnknown is the right answer and not a workaround. The split arm this feeds already requires both sides to positively name a pointee, and says so in its own comment — precisely so a registration path that never recorded one cannot manufacture a phantom overload. "Not resolved yet" is the same claim as "not recorded". This function was handing that guard a placeholder that looked like a real type.

The obvious place to fix it is wrong, and that was measured too. Answering tyUnknown from the alias arm of ParseTypeKind reads better — it is where the placeholder is introduced — and it breaks the compiler's own self-host: make compiler/pascal26 dies at EmitAsmX64: unknown 0-operand mnemonic. Other readers of LastTypePointerElemTk depend on the placeholder and are repaired later through columns the parameter path does not have. The narrow site is the one whose answer is durable and unrepaired.

Not a regression risk in the direction it looks like one

Two same-arity overloads whose pointer parameters are both still-pending forwards now collide at declaration time rather than splitting. Measured: they already collided — procedure P(x: PA) / procedure P(x: PB) with PA, PB both forward gives duplicate definition of 'TL.P' ... the later body wins and runs the later body for both calls, and the PINNED compiler gives the identical wrong answer. So this fix does not create that case and does not widen it; it is the one already filed shape of [[bug-pascal-overload-impl-decl-signature-match]]'s family and is unchanged here. fpc refuses that program outright, which is why it is not in the fixture.

Guard

test/test_p_a_forward_pointer_alias_parameter_matches_its_own_body.pas, wired beside the bracket-slot rows. Four rows, byte-identical to fpc 3.2.2.

Provenance

Found by pointing the corpus probe at cclasses.pas with the Finalize(x, n) refusal made RECOVERING rather than fatal, which is the instrument [[umbrella-pxx-compiles-fpc-itself]]'s attempt 6 asked for in its own words: "the honest unit of work is 'make cclasses.pas compile', not 'clear the 150'." The first thing past that wall was this bug. cclasses.pas now reports two errors, both the same wall (Finalize(x, n) at 1726 and 1893) and nothing else.