← board

A parameter after a by-value set parameter reads zero on xtensa

Reduced

Compared against the x86-64 build of the same source, xtensa built with --platform=posix --xtensa-soft-mulhigh:

type TS = set of 0..255;
procedure F(s: TS; x: Integer);
begin writeln(1 in s, ' ', x); end;
...
s := [1,2]; F(s, 30);
  xtensa   TRUE 0        <- x is lost
  x86-64   TRUE 30

The variations, all measured in one run:

signature xtensa
F(s: TS; x: Integer) TRUE 0 — wrong
F(s: TS; x1, x2: Integer) TRUE 0 0 — both lost
F(x: Integer; s: TS) correct
F(r: TPlain; x: Integer) (8-byte record) correct
F(s: TS) alone, copied to a global correct

So the SET's own 32 bytes arrive intact and everything after it does not. That is a word-count disagreement about the set's slot between the caller's argument push and the callee's parameter walk, not a value bug in either.

Why nothing caught it

ABIParamSlotHoldsValueAddr puts x86-64, aarch64 and arm32 on the by-ADDRESS row, so on those three a set param is one word and a trailing parameter cannot be displaced. Only i386, riscv32 and xtensa pass the 32 bytes, and of those only xtensa is wrong — which is the shape CLAUDE.md's "nothing observably differs is a claim about ONE target" section is about, one row further out: here three targets agree with each other and with the oracle, and the fourth does not.

Where to look

compiler/ir_codegen_xtensa.inc's parameter word walk, against compiler/ir_codegen.inc:1770's (the i386/shared one), which counts a by-value set as TypeSlotSize(tySet) — 32 bytes, eight words — in the same loop that counts a by-value record by RecSize and an Int64 as two. The likely defect is xtensa counting the set as one word on one side of the call and eight on the other.

The test

test/test_cross_byvalue_aggregate_params.pas contains the row that found this (Mixed, a by-value record and a by-value set among scalars, x3 printed after both). It is wired on i386, aarch64, arm32, riscv32 and wasm32 and DELIBERATELY NOT on xtensa; the Makefile's xtensa recipe carries the exclusion and this slug. Wiring it green would mean deleting the row that found the bug. Un-exclude when this closes.

Log

RESOLVED 2026-09-04 (frankA)

Fixed in the same session that filed it, one commit later, because the reduction kept getting worse as I looked at it.

The filed symptom was the one that shows. F(s: TS; x: Integer) giving x = 0 is loud and obvious. Under it was the quiet one: the set parameter itself carried only its first four bytes.

  s := [1, 2, 40, 100, 200, 255];  F(s);   { enumerate members }

  x86-64 / i386 / riscv32 / arm32 / aarch64 / wasm32   1 2 40 100 200 255 | count=6
  xtensa (pre-fix)                                     1 2                | count=2
  xtensa (pin v403, through the wired test)            1 2 64 128 160 192 193 194
                                                       195 198 200 ... | count=26

The count=26 reading is the honest one: the untouched 28 bytes of the slot are not zeroes, they are live frame data, so the parameter answers with whatever the frame last held. The count=2 reading came from a different frame state in a smaller program. Both are the same defect and neither is reproducible as a constant — which is the argument for asserting the whole enumeration rather than a member count.

A [1,2] probe passes on the broken compiler, and [1,2] is what one writes. Word 0 is the word that does arrive, and for a set with no high members the missing words read as "not a member", which is the correct answer. The guard has to put a member in every 32-bit word of the mask, including the top bit, or it cannot fail.

The fix

compiler/ir_codegen.inc, EmitParamSpillsForTarget, TARGET_XTENSA arm: a tySet branch that copies all eight words (register or overflow-stack source per word, exactly as the neighbouring 64-bit arm does) and advances pw by eight. No even-word alignment skip, matching the caller, which does not emit one either — the convention is internal, so the two sides must agree with each other rather than with gcc.

The word count is now TypeSlotSize(tySet) div 4 on both sides, where the caller had a literal for j := 0 to 7. Two literals in two files is how the sides stop agreeing, and arm32/riscv32 already record what a two-of-three state costs for the 5-8 byte record: fixing the count without the slot turns data loss into active corruption.

Guard

test/test_cross_byvalue_aggregate_params.pas, wired on all six cross targets (the xtensa exclusion this ticket created is removed in the same commit). Its ByValSetWide row enumerates every member of [1,2,40,100,200,255], and Mixed now carries a high member too so the trailing-parameter row and the mask row fail independently.