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
- 2026-09-06 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit aa6f20da9.