← board

Measured 2026-09-06, pxx HEAD and pinned agreeing (so: not the VMT-slot fix)

{ MIDDLE — WRONG }                        { ROOT — CORRECT }
TA = class procedure Say; virtual; end;   TA = class procedure Say; virtual; abstract; end;
TB = class(TA) procedure Say; override; abstract; end;
TC = class(TB) procedure Say; override; end;   TB = class(TA) procedure Say; override; end;

a := TC.Create; a.Say;                    a := TB.Create; a.Say;
  pxx: A      fpc: C                        pxx: B      fpc: B

Third control, isolating abstract rather than depth: the same three-level chain with B's override carrying a real body prints A B C under both compilers, matching fpc exactly. So depth is fine, dispatch is fine, and an abstract ROOT is fine — only an abstract row between the declaration and the concrete override loses.

Where to start (not yet confirmed — this is a lead, not a finding)

FindParentVirtualSlot (symtab.inc) walks ancestors looking for a row with UMthVirSlot >= 0, and its inner FindUMeth(curr, name) ALSO walks ancestors. Both loops start at the same class, so the inner one can answer for a class the outer one has not stepped to yet. If an abstract row carries UMthVirSlot = -1, the composition is what decides whether the walk resumes at TA or stops — and the observable says C's override ends up with no slot to claim, since A's body reaches C, which is what an unfilled/inherited slot looks like.

Confirm before fixing by printing UMthVirSlot for each of the three rows rather than reasoning about the loops; the two nested walks are exactly the shape where a plausible story is available for either answer.

Why it is worth a slot in the queue

override; abstract; mid-hierarchy is ordinary OOP — it is how a base implementation is deliberately withdrawn from a subtree. The failure is silent and returns the ancestor's answer, so it is the expensive kind: a plausible wrong value far from the cause, with no crash to locate it.

Log