WriteLn of a WideChar prints its ordinal, not the character
- Type: bug (silent wrong OUTPUT, no diagnostic) — Track A (the type kind is core; the conversion sites are the Pascal frontend)
- Status: done
- Opened: 2026-08-21, from the differential run in [[refactor-centralize-managed-string-pchar-conversion]]
Measured
Six WideChar sources × five string contexts, each program diffed against
fpc 3.2.2. Every context CONVERTS correctly except WriteLn:
var w: WideChar;
begin
w := 'A';
s := w; WriteLn(Length(s), ' ', s); { both: 1 A }
s := 'x' + w; WriteLn(s); { both: xA }
Show(w); { both: A }
WriteLn(w); { FPC: A pxx: 65 }
end.
Same for a record field, an array element, a function result and a
WideChar(n) cast — the writeln row is the only one that diverges, and it
diverges for ASCII too, so it is not the encoding question below.
Why
WideChar collapses to tyUInt16 with no marker
(pasparser_lval.inc:4636: NodeIsWideCharVal recognises only the CAST node,
AN_PTR_CAST with the -3 sentinel). The assign/concat/argument paths work
around that by ALSO accepting "any tyUInt16 flowing into a string context",
which is safe there because a bare Word in a string context is meaningless
anyway.
WriteLn cannot use that trick: WriteLn(someWord) must print the number, and
WriteLn(someWideChar) must print the character, and by the time the argument
is lowered the two are the same type. So this is not a missing call to an
existing helper — it is missing INFORMATION.
The fix, and the precedent that settles it
UCS4Char already has its own kind, tyUCS4Char, added for exactly this
reason: WrapUCS4ToUTF8's comment says the kind is what makes the conversion
decidable, "storage is tyUInt32's". WideChar is the same problem one width
down and should get tyWideChar the same way:
- a
tyWideCharkind whose storage istyUInt16's, var w: WideChardeclares it,WideChar(x)casts to it,- the existing "any tyUInt16 in a string context" fallbacks then narrow to it (they can stay as a fallback — additive, per this ticket family's rule),
WriteLngains the one arm it cannot have today.
Not attempted in the session that found it: a new type kind touches the shared
defs.inc numbering, and the finding is worth more written down than half
applied.
NOT part of this ticket: the encoding divergence
The same differential shows s := w for a NON-ASCII code unit giving 2 bytes
under pxx and 1 under FPC — pxx encodes UTF-8 (__pxxWideCharToUTF8), FPC
converts through the system codepage. That is a deliberate dialect choice, it
is consistent across every conversion site, and it is not a defect. It belongs
in user-facing docs (Track D) rather than in a bug ticket; noting it here so
the next reader of that diff does not re-file it.
Gate
Track A's: make compiler/pascal26 (byte-identical fixedpoint) +
tools/gate.sh quick, plus a WriteLn(w) row against FPC's output.
Fixed — 2026-08-22
Exactly the four steps this ticket proposed, and the precedent held all the way:
tyWideChar, kind 31 indefs.inc, appended at the tail so no ordinal shifts.TypeSize31 → 2,TypeIsOrdinal31 → True; unsigned, soTypeSignedneeds nothing.var w: WideChardeclares it —pasparser_decl.inc's name table andOrdinalNameToTkboth now answertyWideCharwhere they answeredtyUInt16. TheWideChar(x)CAST needed no edit at all: that site already doesASTTk[node] := Ord(OrdinalNameToTk(name)), so it picked the kind up for free, and its-3marker still rides along.NodeIsWideCharValanswers on either evidence — the-3cast marker (all a cast leaves) orASTTk = tyWideChar(what survives a variable). The...or the operand is tyUInt16clauses beside its call sites were LEFT IN PLACE: additive, per this ticket family's rule, and retiring them is its own migration.WriteLngained the arm it could not have, in the same two-place shape the char-array wrap beside it already uses — rewriteASTLeft[item]for theAN_ARG/AN_PAIRforms (rewritingitemwould break the argument chain inASTRight[item]), and a bare branch in the dispatch below.
Measured, before and after
63 programs, WideChar source shape × context, each diffed against fpc 3.2.2.
29 ok before → 38 after, with ZERO regressions. The nine are every writeln
shape: bare variable, cast, record field, array element, function result, two
in one call, interleaved with literals, and Write + bare WriteLn.
The regression half mattered more than the fix half, because a new type kind is
exactly the change that quietly costs a type its ordinal-ness — 68 sites name
tyUInt16 and most are in [...] sets that kind 31 is not a member of. None of
them turned out to be load-bearing here: comparisons, Ord, Inc/Dec,
Succ/Pred, case, for, array indexing, var/const parameters, record
fields, SizeOf and every string context all still agree with FPC. That is the
same result tyUCS4Char got, and for the same reason — everything that cares
about SIZE or ordinal-ness routes through TypeSize / TypeIsOrdinal, which is
why registering the kind in those two functions is the whole cost.
What did NOT become identical, and why that is still progress
The 25 remaining diffs are all the non-ASCII encoding divergence this ticket
already excluded: pxx encodes a WideChar as UTF-8, FPC converts through the
system codepage, so WideChar(233) is two bytes here and one there. WriteLn
used to be the one context that dodged this by printing 233 instead —
now it prints the UTF-8 é like every other conversion site in pxx does. So the
writeln row moved from wrong to consistently divergent, which is the
dialect's documented position
(devdocs/dev/pascal-dialect-divergences.md), not a defect.
Test
test/test_widechar_writeln_prints_the_character.pas, wired into test-core.
Two halves on purpose: the eight writeln shapes, then the ordinal battery that
the new kind could have broken. Byte-identical to fpc 3.2.2. ASCII only —
a non-ASCII row would encode the divergence above into a test and make the
oracle useless.
Gate
make compiler/pascal26 (byte-identical fixedpoint, converged in 1 round) +
tools/gate.sh quick.
Log
- 2026-08-22 — resolved, commit 7d725f0d8.