chr() of a variant returns the wrong character — the slot is read as an ordinal
xs = [97, 98]
print(chr(xs[0])) # CPython: a pxx: X
out = ""
for o in [97, 98]:
out = out + chr(o)
print(out) # CPython: ab pxx: (empty)
ord() is fine; only chr() is affected.
Measured
| expression | CPython | pxx |
|---|---|---|
chr(97) (literal) |
a |
a ✓ |
o = 97; chr(o) (int var) |
a |
a ✓ |
chr(int(xs[0])) (explicit unbox) |
a |
a ✓ |
chr(xs[0]) (list element) |
a |
X |
chr(o) where o is a loop variable |
a |
empty |
chr(o) where o is an unannotated param |
a |
empty |
chr(xs[0] + 1) |
b |
x |
ord(xs[0]), ord(<param>), ord(<loop var>) |
correct | correct ✓ |
The explicit chr(int(v)) working is the tell: the value is fine, the unboxing
is missing.
Cause
parser.inc, the shared Ord/Chr soft-keyword intrinsic. Ord has a
special case that routes a non-ordinal operand to pylib:
{ Python has no char type: `ord("a")` takes a 1-length STR, which the
ordinal intrinsic would read as the string's pointer. Route a string
operand to pylib instead ... }
if (op = Ord(tkOrd)) and PyExprMode and
((IntToTypeKind(ASTTk[CurASTNode]) = tyString) or
(IntToTypeKind(ASTTk[CurASTNode]) = tyAnsiString)) then
Chr has no equivalent arm, so a tyVariant argument falls through to the
-tkChr intrinsic, which reads the 16-byte variant slot as an ordinal — hence a
character derived from the tag/handle rather than from the payload.
Same "the sibling grew a case and this one did not" shape as [[bug-nilpy-indexing-an-unannotated-str-parameter-segfaults]].
Fix
Give Chr the mirror arm: when PyExprMode and the argument is tyVariant,
wrap it in pyvar_to_int before the intrinsic (that helper already applies
Python's numeric rules and raises TypeError for a non-number). Keep the literal
and int-typed paths untouched so ordinary chr(65) stays a single instruction.
Gate
make test-nilpy + self-host byte-identical, plus a .npy of the table above
against CPython's own output, and a caesar-cipher round trip
(chr(((ord(c) - 97 + k) % 26) + 97) over a loop variable) as the end-to-end
case — that is the program this was found in.
Log
- 2026-07-30 — resolved, commit f6f5e1838.