← board

A string-tagged address binop walls off NilPy on three targets

Measured

53 of those 61 were the same one-line refusal, and it is not a record bug at all — it is every NilPy program:

$ cat v1.npy
def main():
    a = 1
    b = 2
    c = a + b
    print(c)
main()

$ ./compiler/pascal26 --target=i386  v1.npy out
pascal26: error: target i386: non-integer binop not yet supported
$ ./compiler/pascal26 --target=arm32 v1.npy out
pascal26: error: target arm32: non-integer binop not yet supported

Nothing to do with the test's own code: the wall is in the NilPy runtime (compiler/builtin/pyeval.pas), which every .npy program links. So the whole NilPy frontend has been dark on the 32-bit cross targets — 53 tests that read as "record/variant gaps" in a sweep were one gate in three backends.

Root cause

Error at codegen time carries the LEXER's position, which pointed at an unrelated line in another unit; the type kinds were not printed at all. Both are fixed here (see below), and with them the wall reads:

target i386: non-integer binop not yet supported: ShortString
  (op token 71, operands NativeInt, NativeInt) in PyBindHostKwArgs

PyBindHostKwArgs contains PString(NativeInt(pk[arity + p + 1]))^. IR:

397: load_mem                      tk=Int64      { pk[i] }
399: binop  and $FFFFFFFF          tk=NativeInt  }
401: binop  xor $80000000          tk=NativeInt  }  NativeInt(x) on a 32-bit
403: binop  sub $80000000          tk=String     }  target: mask + sign-extend
404: store_sym                     tk=AnsiString

Node 403 is created as tyNativeInt by the narrowing-cast lowering and then retagged to tyString in place by the ^ — deliberately, and documented in ir.inc:

A frozen-string / set value IS its address (no load). Present the address node with the value's frozen-string tag […] otherwise it carries IRLowerAddress's tyPointer tag and a consumer misreads the address as a raw pointer.

So the tag is right and the node is right. What is wrong is the gate:

if not (TypeIsOrdinal(tk) or (tk = tyBoolean) or (tk = tyUnknown)) then
  Error('target i386: non-integer binop not yet supported');

The result TAG does not choose the operation — the OPERANDS do. The 64-bit branch three lines above already knows this ("a comparison carries a Boolean result tk, so key off the operands") and the same file forgot it one branch later. x86-64 has no such gate anywhere in IR_BINOP; these three invented one.

Fix

BinopOperandIsMachineWord (new, symtab.inc) — ordinal, pointer, tyString, tySet, tyUnknown: the kinds whose value is a machine word in a register. The gate now passes when the result tag is ordinal or both operands are machine words. Genuinely non-word operands (variant, record, a float that escaped the branch above) are still refused.

Diagnostics, in the same change and worth keeping on their own:

Effect, and the walls behind it

target before after
arm32 refused builds (then SIGILLs at runtime — next wall)
i386 refused next wall: symbol kind not supported yet (load)
aarch64 refused next wall: aggregate result with more than 8 params
riscv32 mmap not supported on bare-metal target unchanged

None of those are this ticket. Filed as follow-ups so the NilPy-on-cross campaign is visible rather than rediscovered by the next sweep.

Gate

Self-host fixedpoint + tools/gate.sh quick; the 53-test dyn-array/interface and 194-test record/variant cross differentials no worse than baseline.

Resolution (2026-08-21)

Fixed as described above. Verified:

The follow-up ticket carries the arm32 diagnosis this fix uncovered: the SIGILL after a successful build is the NilPy driver emitting an x86-64 entry stub unconditionally, and EmitMmapArena silently emitting x86-64 for i386, arm32 and aarch64 while refusing xtensa and riscv32. Same disease as this ticket, one level up: a target dispatch that refuses some targets and lies to the rest.

Log