← board

The last red cell of the opener × chain census

type TRec = record a, b: Integer; end;
     PRec = ^TRec; PPRec = ^PRec;
var pp: PPRec;
function GetPP: PPRec; begin Result := pp; end;
...
WriteLn(GetPP^.a,  ' ', GetPP^.b);    { pxx 4306192 0   fpc 11 22 }
WriteLn(GetPP^^.a, ' ', GetPP^^.b);   { pxx 11 22       fpc 11 22 }

The explicit spelling is right, so this is not about the alias table and not about the record identity — the address computation is one level short.

What is measured, and what is not

bug-p-a-cast-to-a-pointer-to-pointer-drops-the-implicit-second-deref and bug-p-an-implicit-deref-over-a-typed-pointer-cast-is-dropped are closed and this is the cell they left. The 41-row differential (8 openers × field / second field / method / property, plus two already-a-record negative-control openers, plus a must-differ control) reads agree=36 DIFFER=5, and four of those five are these rows; the fifth is the control.

MEASURED: PXXDBG='a.symptr:*' shows GetPP's Result symbol as kind=17 depth=2 ptrElemTk=17 baseTk=5 baseRec=23 ptrElemAlias=14 — the full triple, on the symbol.

NOT ESTABLISHED: whether ProcRetPtrDepth[procIdx] (a different array, and the one ApplyCallResultPtrSuffix reads) carries it. Seeding recId from ProcRetPtrBaseRec in that routine's tkDot arm changed no row — and that probe cannot tell the two cases apart, because if the depth is recorded then the caret arm has already set recId := ptrBaseRec and the seeding is a no-op. A guard that cannot fail is not a guard, so the reading that "the triple is empty" is withdrawn rather than reported.

The next step, and why it needs a different instrument

A canary inside the arm is the obvious probe and it does not survive the self-host: compiler.pas itself takes that arm, so the build fails before the repro runs, and the compiler's own subset has no IntToStr to print the state with. So the discriminator wants either a new PXXDBG topic for ProcRet*[procIdx] at the call site — the sibling of a.symptr, which answers about SYMBOLS and is exactly why the symbol reading above is not evidence about the proc table — or a two-arm A/B where the seeding is applied unconditionally rather than only when recId = REC_NONE.

Reached from refactor-p-three-hand-rolled-postfix-loops: the call-result walk is one of the three, and this is the last shape where it still disagrees with the shared walker it delegates to.

2026-09-06 — the observable changed under it: a FALSE REFUSAL, and it is the same defect

frankB landed f9476d579 while this ticket sat open. It makes a pointer chain that is one dereference SHORT refuse rather than answer a number, and frankB asked to be told if a fix here made GetPP^.a resolve — with the warning to check that GetPPP^.a still refuses rather than becoming a number.

The measurement is the inverse of that expectation, and no fix was applied. Measured at 85c81be85, binary faa41e4b920f, on a 41-row opener x chain differential that moved from agree=36 DIFFER=5 PXX-REFUSES=0 to agree=36 DIFFER=3 PXX-REFUSES=2:

program pxx fpc 3.2.2
GetPP^.a (one short, call-result opener) refused"a": this value is still a POINTER here compiles, prints 11 22
GetPPP^.a (two short, call-result opener) refused refused — Illegal qualifier
pp^.a (one short, pointer VARIABLE) 11 11
ppp^^.a (one short, pointer VARIABLE at depth 3) 11 11

So the new arm is correct on the two-short chain (row 2 is frankB's arm doing exactly its job, confirmed against fpc) and false on the one-short chain, and it is false only on the call-result opener. The pointer-variable twins in rows 3-4 take the same one-short chain and still resolve.

That opener is this ticket. Where the depth is lost, a legal one-short chain is indistinguishable from a two-short one, so a refusal aimed at the two-short case necessarily swallows it. The two are not adjacent bugs to be sequenced — they are one missing fact (ProcRet* depth at the call site) observed through two different arms. Fixing this ticket removes frankB's false positive as a by-product; nothing needs to be reverted.

The two remaining wrong-VALUE rows are GetPP^.GetA and GetPP^.PA — both 4306192 against fpc's 11 — which are the same loss reaching the method and property member kinds, where no refusal arm stands in front of them.

Whoever takes this inherits frankB's control as a required row: after the fix, GetPPP^.a must still refuse. It does today, and it must not become a number.

2026-09-06 (frankD) — resolved: the third opener

The two branches I wrote for the previous two tickets were a hand-maintained enumeration, and its own comment said "the chain can bottom out on a CAST as well as a variable" as though that closed the set.

variable  AN_IDENT     SymPtrDepth      SymPtrBaseRec
cast      AN_PTR_CAST  AliasPtrDepth    AliasPtrBaseRec
call      AN_CALL      ProcRetPtrDepth  ProcRetPtrBaseRec   <- was missing

Three tables spelling one distinction, and in all three the ULTIMATE base is correct and the IMMEDIATE pointee silently does nothing. That is the third time this exact wrong-half choice has been available in this function.

What the ticket asked for is NOT what settled it. It proposed a new PXXDBG topic for ProcRet*[procIdx] at the call site, on the grounds that a.symptr answers about SYMBOLS. That instrument was never built and was not needed: the question was not whether the proc table carries the depth (it does) but which node kinds ResolveNodeRec enumerates, which is a reading of three branches. The earlier probe that "changed nothing" — seeding recId from ProcRetPtrBaseRec in the dot arm — was correctly withdrawn as non-discriminating, and it was also aimed one layer too high: by then the arm had already declined.

The refusal and the wrong value were one defect seen from two sides, which is why the ticket's own "pre-existing: identical on pin v404" was true of the VALUE and false of the REFUSAL.

Log