A sized boolean is true and not-true at the same time
ORDERING HAZARD — READ THIS BEFORE DOING EITHER TICKET. Every spelling
Boolean16can have TODAY is already a defect:tyUInt16gets the width right andnot/WriteLn/Ordwrong;tyBooleangets those right and the width wrong (defs.inchas exactly two boolean kinds,tyBooleanandtyBool8, and both are ONE BYTE). So adding the four names moves the corpus wall forward while shipping four more instances of the sibling bug — it looks like progress and it is not. That convertsdecide-how-a-type-carries-an-identity-its-kind-cannot-holdfrom an argument to have before this work into a SCHEDULE for it: the fork is not optional here, it is the ordering constraint.
var a: ByteBool;
a := True;
if a then Write('then '); { fires — correct }
if not a then Write('not-then '); { ALSO FIRES }
Measured 2026-09-05, HEAD and pin v403 identically; fpc 3.2.2 prints then
only.
pxx fpc
ByteBool True then not-then then
ByteBool False not-then not-then
Boolean True then then
not a is always true for a sized boolean. The value is an integer, so
not is a bitwise complement: not 1 is 254 and not 0 is 255, both nonzero,
both read as true. The False row is right BY ACCIDENT — which is why this
survives casual testing, and why a probe that only sets False cannot see it.
Comparisons are unaffected: a = True and a <> False both answer correctly,
so the bug is specific to the not lowering rather than to the type's truth
test.
Cause, and why it is not a local patch
pasparser_lval.inc maps the sized booleans onto integer kinds ON PURPOSE, and
says so:
"FPC's sized booleans are C-ABI booleans — a fixed-width integer where nonzero means true. They must keep their WIDTH (LongBool 4, WordBool 2, ByteBool 1); mapping them onto tyBoolean would silently resize any struct or call that crosses to C."
That reasoning is right and the width IS right (1/2/4, matching fpc). What the
comment weighed was two options — map to tyBoolean and lose the width, or map
to an integer and keep it. There is a third: keep the integer kind for layout
and record the boolean-ness beside it, the way an enum is tyInteger plus an
id rather than a kind of its own. Nothing in the tree can currently express
that for these types, so not, WriteLn and Ord all see a plain integer.
Three symptoms, one cause:
| pxx | fpc | |
|---|---|---|
not a when a is True |
true | false |
WriteLn(a) |
1 |
TRUE |
Ord(a) when True |
1 |
-1 (all bits set) |
Ord is the C convention rather than a quirk, and it is the tell that these are
ABI types: a value that crosses to C must be all-bits-set, not 1.
QWordBool does not exist at all — unknown type: QWordBool, where fpc gives
it 8 bytes. It is deliberately NOT being added first: adding a fourth member to
a family whose not is broken is not progress, and it would inherit every row
above.
The fork this needs settling first
Adding a distinct kind trio (tyByteBool/tyWordBool/tyLongBool) touches
TTypeKind in defs.inc, which CLAUDE.md names as the one thing to coordinate by
message rather than just edit. The alternative — a side channel like the enum
id, carried through symbols, record fields, params and the alias table — is the
same shape as
[[bug-p-a-type-alias-drops-the-enum-identity-and-a-set-drops-its-char-element-kind]]
and would fix both families at once. Pick one before writing code.
Why prio 55
Higher than the display and identity bugs beside it because this one makes a compiling program take BOTH branches of a conditional with no diagnostic, and the affected types are exactly the ones an FPC binding to a C library declares. A wrong answer that announces itself is cheaper than this.
Found by
Chasing tenum6.pp, whose skip reason reads "gap: Str() of boolean /
ByteBool / WordBool / LongBool / QWordBool ('FALSE')". Str(True:0, s) works
fine here — the reason is a symptom label again, and the mechanism underneath it
is nothing to do with Str.
The fork is filed
[[decide-how-a-type-carries-an-identity-its-kind-cannot-hold]] (Track U, p55) carries the two options, the trade-offs and a recommendation. Do not start here — the choice made there decides how this is fixed, and picking wrong makes the enum-identity family wider rather than merely later.
Re-measured 2026-09-06 — frankB (commit d3fe44947, compiler 827722c842de, fpc 3.2.2)
Independently reproduced, still live. I filed a duplicate of this ticket before
finding it and retracted it in the same commit; everything in it except the
BooleanNN names was already here, and this ticket's diagnosis was better —
it names the defs.inc fork, mine only named the mapping.
But the NUMBER is not a duplicate — it is a second instrument, and the pair
is the finding (frank-coordinator's catch). This ticket measured ByteBool
and got not 1 = 254: unsigned, one byte, via tyUInt8. I measured
LongBool and got not lb = -2: signed, four bytes, via tyInteger.
Same defect, and the two land nonzero for different arithmetic reasons.
Neither reading could have gone wrong the way the other did, which is what
makes them corroboration rather than repetition — and it is what shows the
defect is the MAPPING and not one type's arithmetic. Keep both numbers.
Re-ranked 55 -> 70 on this justification: it is a silent control-flow
inversion in the one type family that exists specifically for C and Win32
interop — i.e. exactly where real source writes if not SomeApiCall(...) then.
The prio predated the if not lb then / else spelling, which states the harm
more plainly than "both branches fire".
The else arm makes the same defect read as a plain inversion rather than as
a double fire, which may be the more recognisable spelling for whoever fixes it:
lb := True;
if not lb then WriteLn('taken') else WriteLn('skipped');
pxx -> taken fpc -> skipped
not lb for a LongBool is -2 (not 1 at 32 bits), so it is nonzero and
therefore true.
A control that passes under this bug, recorded because I used it
I checked "does any nonzero read as TRUE" first and got a clean match with fpc
— 2 reads TRUE, 256 reads TRUE, 0 reads FALSE — and concluded from it that the
divergence was confined to the value STORED by a := True. That control is
on the arm that works. It exercises if a; the defect is in if not a. It
passes under the bug and it certified the wrong conclusion, which would have
downgraded this to a display-and-Ord difference.
Consequence for anyone triaging: this cannot go to known-incompat/. That
folder is for two behaviours each correct about their own implementation, and a
type where a and not a are simultaneously true is not correct about
anything. The Ord(True) = 1-vs--1 row is not a chosen representational
difference either — it is one visible face of the mapping that also makes not
wrong, so it resolves with this and needs no separate "does real source mean
it" analysis.
And an ordering hazard, from how I got here
Boolean16/Boolean32/Boolean64 are missing entirely (fpc: 2, 4, 8 bytes)
and are filed separately as a feature gap —
feature-p-the-booleannn-family-of-explicit-width-boolean-type-names. That
gap is a real corpus wall: uthlp.pp needs Boolean16 and twelve tthlp*
files use that unit.
Fix the names first and the corpus moves one wall forward while this stays
in. A missing name produces an error message and a wrong branch does not, so
the feature gap is the one that recruits an owner. Whoever picks up the
BooleanNN ticket should read this one first — they land in the same table.
2026-09-06 (frankA) — a FOURTH face, and this one is asymmetric between two spellings
The summary lists three consequences of one cause: not complements an integer,
WriteLn prints a digit, Ord answers 1 instead of -1. There is a fourth, and
unlike the other three it depends on how the type is SPELLED:
High(ByteBool) -> REFUSED
type b = ByteBool; High(b) -> 255 (WordBool 65535, LongBool 2147483647)
fpc 3.2.2, both spellings -> TRUE
The refusal is a mitigation and the one-line alias routes around it.
bug-p-thirteen-builtin-type-names-answer-at-some-doors-and-are-refused-at-others
records that the direct door refuses on purpose — answering High from the kind
would give an ordinal — so that door is declining a question it cannot answer
correctly, and the alias door has no such scruple. A refusal is loud and a wrong
bound is silent.
It matters for THIS ticket because it changes what the fork is exclusively
required for. Making High answer TRUE needs the identity carrier, same as the
other three faces. Making the two spellings AGREE does not: the alias path can
refuse exactly as the direct path does, today, independently of how
decide-how-a-type-carries-an-identity-its-kind-cannot-hold is settled. So there
is a defensible interim state that is strictly smaller than the fork, and it was
invisible until someone asked the second spelling.
Found by sweeping all 51 names from the union of OrdinalNameToTk and
BuiltinScalarTypeKind in both spellings; these three are the only cell in that
sweep where the spelling changes the answer. Detail and the negative half — zero
direct-vs-alias disagreements at SizeOf, High/Low and TypeInfo for every
other name — are on the thirteen-names ticket.
Log
- 2026-09-06 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit a3532e1f8.
Fixed 2026-09-06 (frankH) — and the aperture was wider than this ticket
71b5bac58, with the identity vehicle in ef518700b. Arm B of
[[decide-how-a-type-carries-an-identity-its-kind-cannot-hold]], as decided.
not is normalised, not re-tagged, and the difference is the whole fix. The
first attempt set bitNot := False and let the logical path have the operand as
it stood. That path lowers to xor 1 — correct for a canonical 0/1 Boolean
and wrong for a C-ABI one: ByteBool(200) is $C8, $C8 xor 1 is $C9, still
nonzero, still true. So the operand is converted to an ordinary x <> 0 once,
at the top of the expression parser, and not learns nothing.
Caught only because the test carries a nonzero-but-not-one row. Every row in this ticket used 0 and 1, and on those inputs the broken fix and the correct one agree exactly.
THIS TICKET'S "COMPARISONS ARE UNAFFECTED" WAS TRUE OF THE VALUES AND FALSE OF THE TYPE
Same cause, and no ticket had it, for the same reason. With
a := ByteBool(200) and b := ByteBool(1), both true, against fpc 3.2.2:
| was | fpc | |
|---|---|---|
a and b |
0 (bitwise 200 and 1) |
TRUE |
a or b |
-55 | TRUE |
a = b |
FALSE (raw ordinals) | TRUE |
and, or and all six comparisons now normalise both operands when at least
one side is a sized boolean and both sides are truth values — never when the
other side is an integer, so a and 3 stays a type error to be diagnosed
rather than being quietly rewritten into something that compiles.
xor is deliberately left BITWISE and that is measured: fpc answers
Ord(a xor b) = -55 while Ord(a and b) = 1. Bit patterns are meaningful in
the type family that exists for the C ABI. Its result keeps the sized-boolean
identity, so WriteLn(a xor b) prints TRUE while Ord of the same expression
is the pattern.
The ORDINAL of a logical result is not asserted anywhere and must not be.
fpc's own answer moves with the SHAPE of the operands — 1 for two variables,
-56 (the left operand's own bits) when the right side is written ByteBool(1).
There is no ordinal there to be compatible with; the declared type's value is
the claim.
The Ord and display rows resolved with it, as this ticket predicted
Ord needed two independent fixes and this ticket's diagnosis named only
one of them. tyInt8/tyInt16 (signedness) fixes Ord(ByteBool(255)) = -1;
materialising True as all-bits-set fixes Ord(b) after b := True.
LongBool was already signed and still answered 1, which is the control that
separates them.
QWordBool is still not a type name — untouched, and it inherits none of the
above now rather than all of it.
Tests
test_a_sized_boolean_is_not_true_and_not_true_at_once (this ticket) and
test_a_sized_boolean_is_a_boolean_at_every_renderer, both byte-identical to
fpc 3.2.2's own output, both wired into the Makefile. The False rows are marked
in the source as the ones that cannot fail.