An explicit cast with no capacity match falls back to the wrong conversion operator
{ toperator91.pp, reduced }
class operator TTest.Explicit(const a: TTest): ShortString; { pxx picks this }
class operator TTest.Implicit(const a: TTest): ShortString; { fpc picks this }
...
s40 := TString40(t); { no Explicit operator has capacity 40 }
if ImplicitShortString <> 1 then Halt(3);
An explicit cast writes its destination, so capacity discriminates among the
Explicit operators — that half landed 2026-09-07 and TString80(t) /
TString90(t) reach their own operators. What is unsettled is the FALLBACK when
no Explicit operator has the destination's capacity. pxx takes the generic
Explicit one (OpConvResultCapRank's generic-fallback rank, which exists
because removing it segfaulted); fpc takes the generic Implicit one.
Why it was split out rather than fixed alongside
It changes the explicit-cast fallback path, and that path has a live hazard with
a dated cost: s40 := TString40(t) with only a ShortString conversion declared
once fell through to a raw record-to-string store and SEGFAULTED, where fpc uses
its ShortString overload. OpConvResultCapRank in symtab.inc carries that
note. Any change here must keep a fallback — the question is only WHICH operator
it lands on, never whether there is one.
The control already exists
test/test_conversion_operator_result_capacity_is_part_of_its_identity.pas pins
the explicit-cast behaviour that must survive, and toperator91.pp is the row
that goes green. Take them together.
Resolved 2026-09-09 — there is no generic Explicit fallback at all
The rule is not "prefer the generic Implicit one". It is:
- An
Explicitoperator serves a frozen-string destination only at its own capacity. No generic exemption. - A cast matching none of them is not an explicit conversion, and retries
the IMPLICIT lookup — where the generic exemption is real and a bare
ShortStringresult does serve every destination. - Nothing there either → refuse.
Read off toperator91 rather than inferred: ExplicitShortString is never
incremented anywhere in that program, including the two rows whose capacity
no Explicit operator has. fpc prints ShortString Implicit for both. An
operator that is declared and never called on any row is a stronger statement
than "the fallback picks the other one".
pxx now prints all eight lines and ok, exit 0, byte-identical to fpc 3.2.2.
This ticket's central caution was wrong
Any change here must keep a fallback — the question is only WHICH operator it lands on, never whether there is one.
fpc does not keep one. With only a non-matching Explicit declared and no
Implicit, fpc refuses:
only_exp.pp(13,10) Error: Illegal type conversion: "TTest" to "TString40"
pxx ran the wrong operator and printed a result. So the segfault that the generic-Explicit fallback was protecting against is closed by a diagnostic, which is what fpc does and is strictly better than reaching a conversion fpc would not use. The refusal is narrowed to frozen-string destinations: a record cast to a pointer or to another record is a legitimate reinterpret and still falls through. A string destination has no sane raw reading — the length byte is the first byte of whatever the record holds.
The control had to change, and it was the same mechanism
test_conversion_operator_result_capacity_is_part_of_its_identity.pas row 3
asserted that TS40(t) reaches the generic ShortString Explicit
conversion, and said in its own comment that fpc refuses that cast — kept as a
deliberate leniency because the alternative then was the segfault.
That leniency is this bug. One rule cannot serve both, so row 3 was
re-aimed rather than deleted: the file gained an Implicit ShortString
operator, and row 3 now asserts the retry landing on it (toSSimp, never
toSS). All three rows are now byte-identical to fpc, where the file
previously diverged on one. The refusal case moved to
test_conversion_operator_no_capacity_match_is_refused.pas.
The pair is the point. One row shows the retry landing on the generic Implicit conversion; the other shows the refusal when there is nothing to retry with. Neither alone separates a working retry from a refusal that swallows every cast — which is the shape this change could most easily have had.
Ambiguity refuses rather than picking: with two sized results and no generic,
fpc does not choose (OpConvImplicitResultRank's fifth measured row), and
silently taking one is the accepted-invalid shape this family keeps producing.
Log: fixed and closed in commit 637516cfd.