← board

Symptom

$ cat > m.bas <<'X'
DIM m = "x"
PRINT m
X
$ pascal26 m.bas m && ./m
120

120 is Ord('x'). PRINT "x" written directly prints x, so the write path is fine and the variable is where the string-ness is lost.

Reproduced on stable_linux_amd64/default/pinned as well as HEAD — this is not new.

Where to start

bparser.inc's DIM/LET arm decides the variable's type from the RHS:

    vtype := tyInteger;
    if ASTTk[rhsNode] = Ord(tyString) then
      vtype := tyString;
    symIdx := AllocVar(vname, vtype);

So the type is only preserved when the RHS node is tagged tyString exactly. Confirm what a BASIC string literal is actually tagged (BMkRuntimeError builds one with ASTTk[lit] := Ord(tyString), but the PRINT/DIM path may differ), and whether the AN_IDENT node built for m on the read side carries the symbol's kind. PXXDBG=a.ast: and a.ir: answer both in one run — measure it, do not reason about it: a tyString variable that prints its first byte as a number is equally consistent with a mis-tagged declaration and with a mis-tagged reference.

Scope note

BASIC has no A$ string variables at all (10 A$ = "hi" is Undefined variable or procedure: A), so the whole string-variable surface here is DIM/LET of a literal. That makes this small, and it is prio 40 rather than lower because it is a silent wrong answer in the most obvious two-line program a BASIC user writes.

Found by

Measuring the BASIC driver's runtime needs while closing [[bug-a-a-unit-free-basic-program-calls-a-helper-it-never-emits]].

Resolved 2026-08-24 (claude-A)

Sharper than the ticket says: only a ONE-CHARACTER literal. Measured:

program before after
DIM m = "x" / PRINT m 120 x
DIM m = "hello" / PRINT m hello hello
PRINT "x" (literal, no variable) x x
m = "x" (LET-less form) / PRINT m 121 for "y" y

That pattern names the cause exactly. The RHS comes from the shared Pascal expression parser, which tags 'x' as tyChar and 'xy' as tyString; both copies of BASIC's variable-typing rule tested ASTTk[rhsNode] = Ord(tyString) alone, so a one-character initialiser fell through to tyInteger and the write printed a number.

Same root as the PChar + divergence recorded in [[refactor-centralize-managed-string-pchar-conversion]]: "With a ONE-char literal — tyChar, an ordinal — the ordinal + pointer arm claimed the expression first." A one-character string literal has now produced a silent wrong answer through two unrelated code paths.

Fix

BVarTypeForInit(rhsNode) in bparser.inc, replacing both copies of the rule. BASIC has no character type, no A$ sigil and no type declarations — a variable is whatever its first assignment says it is, and there are exactly two things it can hold. So a one-char literal is a string here, and the helper is where that is said once.

The two copies were the DIM/VAR arm and the LET-less I = <expr> arm; the ticket was written against the first, and fixing only that one would have left the second — devdocs/dev/normalise-dont-special-case.md's "grep for the sibling before closing the ticket", which is why the test covers both spellings.

A third sibling this UNCOVERED, recorded not fixed

IF a = "x" used to pass by accident — an integer compare of 120 against 120. Now that these are strings it is a real PXXStrEq call, and on aarch64/arm32 that is compiler error: PXXStrEq not found, because the body ships with builtinheap and a unit-free .bas never pulls it. x86-64 and i386 have an inline path and compile it. Same root as [[bug-a-basic-string-concat-in-a-unit-free-program-is-a-compiler-error]] and recorded there; deliberately kept OUT of this test so the two are not coupled.

Test

test/test_basic_one_char_string_var.bas — both spellings, a multi-char control, and an integer control. Wired into test-core natively and on i386 / aarch64 / arm32. pinned prints 120 and 121 for the two string rows.

Gate

make compiler/pascal26 fixedpoint converged in one round; the four existing .bas tests byte-identical; the new test on four targets; tools/gate.sh quick GREEN.

Log