← board

Taking the address of a float array element is a float operator on the 32-bit targets

Repro — three lines, no float operation in it

program p;
var V: array[0..1] of Double;
begin writeln(PtrUInt(@V[0])); end.
$ pascal26 --target=riscv32 p.pas /tmp/p
pascal26:3: error: target riscv32: unsupported float operator
$ pascal26 --target=i386 p.pas /tmp/p
pascal26:3: error: target i386: unsupported float binop
$ pascal26 --target=arm32 p.pas /tmp/p
pascal26:3: error: target arm32: unsupported float binop

x86-64 and aarch64 compile and run it. A const array of Double fails identically; the var form is shown because it removes typed-const data handling from the picture entirely.

Controls, in both directions

Measured at 4cec00985, self-hosted binary aa78a7faf63a, target riscv32:

program result
var V: array of Double; declared, never referenced OK
V[0] := 1.5 — actual float work, no address taken OK
@V — address of the WHOLE array OK
@V[0] — address of an INDEXED element FAIL
@V[0] where V is array of Int64 OK
@V[0] where V is array of Single FAIL
@D where D is a scalar Double OK

So it is neither "floats are broken on 32-bit" (assignment works) nor "@ is broken" (whole-array and Int64-element forms work) nor "arrays are broken". It needs both a float element type and an index. That pair is the defect's shape, and the two OK rows either side of it are what make it a shape rather than a guess.

Where it is raised, and the part that is NOT yet verified

compiler/ir_codegen_riscv32.inc:1075, the tail of the softfloat binop lowering — the else after the tkPlus/tkMinus/tkStar/tkSlash/tkEq... chain that picks a __pxx_d* / __pxx_s* helper. Siblings: ir_codegen_arm32.inc:2007, ir_codegen386.inc:2579. (ir_codegen_xtensa.inc:1164 is the same construct and is presumably exposed too; not measured — xtensa does not run on this host.)

The mechanism below this line is a hypothesis, not a measurement. The plausible reading is that @V[0] lowers to a base+index*stride BINOP whose result type is inferred from the ELEMENT type, so an address computation — pure integer arithmetic — is dispatched to the float path, arriving with an operator that path has no helper for. That is a guess from the raise site's shape. It has not been confirmed against PXXDBG=a.ir:<proc>, which is the cheap way to settle it and the right first step for whoever takes this (devdocs/dev/debugging-playbook.md: do not theorise about an inferred type, print it).

Why this is not Track F

Rank the mechanism, never the datatype. The subject is address computation and operator dispatch; the observable is a compile failure on valid Pascal, and the program contains no float arithmetic at all. CLAUDE.md is explicit that a missing function a working program calls, or a control-flow/codegen bug that merely lives in float code, stays an ordinary bug in its own lane — and that when it is a close call it is NOT F. This one is not close: fixing it changes no digit of any float result.

Cost

Small but real, and larger than it looks because it is silent until you cross- compile. Any 32-bit program that takes the address of a float array element — passing @Coefs[0] to a routine, a PDouble walk over a table, an FFI hand-off — does not build, on three of six targets, with a diagnostic that names floats in a program that does no float arithmetic. It also blocks the float rows of [[bug-a-a-double-typed-const-misaligns-the-next-const-array-in-the-data-section]]'s regression test, which is how it was found; that test ships with Int64 and Cardinal elements and a note to add the float rows when this closes.

Gate

make compiler/pascal26 + the repro above compiling for i386, arm32 and riscv32, plus the Int64 and whole-array controls staying green. Add the float rows to test/test_const_array_align.pas as part of the fix — the test already carries the comment saying why they are absent.

2026-08-31 — FIXED, and the compile error was the mild half

3c4b50014. The ticket's hypothesis was correct and its scope was not.

What was actually wrong

IR_LEA, IR_FIELD and IR_INDEX all compute an ADDRESS, and their IRTk carries the type of what lives at that address — correctly, because that is what the IR_LOAD_MEM stacked on top needs to know. Measured:

0: lea   a=95 tk=17 [sym=V]          <- tyPointer
1: const_int ival=0
2: index a=0 b=1 ival=8 tk=19        <- tyDouble: the ELEMENT type, on an ADDRESS
3: const_int ival=4294967295 tk=16
4: binop a=2 b=3 c=30 tk=16          <- `and`, with a "float" left operand

Every backend's binop dispatch asks "is either operand a float" as TypeIsFloat(IntToTypeKind(IRTk[operand])). Node 2 answers yes. So a pure address computation was dispatched into the float lowering, arriving with an operator that lowering has no kernel for.

The fix is one shared oracle, IRValueKind in ir.inc, asked by all six backends instead of each reading IRTk directly. This was not a new idea: ir_codegen_wasm32.inc already had the rule privately, as WasmNodeResultType's IR_LEA, IR_FIELD, IR_INDEX : tyPointer arm. Two mechanisms serving one concept is exactly the smell normalise-dont-special-case.md names, and the wasm arm is now an instance of the general rule rather than a second one.

The half the ticket did not know about: x86-64 miscompiles this, silently

The report is three 32-bit targets failing to compile. That is the mild end.

On x86-64, PtrUInt(x) lowers to x + 0 rather than x and $FFFFFFFF — and + is in the float set. At -O0, writeln(PtrUInt(@V[0])) compiled to:

movq     xmm1, rax        ; the ADDRESS, reinterpreted as a double
cvtsi2sd xmm0, rcx
addsd    xmm0, xmm1       ; floating-point add of an address to 0.0
movq     rax, xmm0

It returns the right answer. A finite double plus +0.0 is bit-preserving, and an address is a tiny denormal, so the round trip through the FPU happens to be the identity. Wrong code, right answer — and under DAZ, or for any bit pattern addsd does not preserve, the luck runs out.

At -O1 and above it is not even emitted: an imm-fold arm (ir_codegen.inc:7063) keyed on the node's type rather than the operands' catches binop(x, const, ALU-op) first and emits add rax, 0. So on the target everyone develops on, the defect was absent at the default level and correct-by-luck at the one level where it existed. That is why a bug reachable in three lines of valid Pascal survived.

The generalisable bit: the 32-bit targets did not have a different bug. They had the same bug with no masking arm and no denormal luck, which is the only reason anyone saw it. A target with fewer optimisations is an instrument, not a liability.

Positive control

Same source, procedure Q, x86-64 -O0, two compilers:

float instructions in Q (addsd/movq xmm/cvtsi2sd)
pinned (pre-fix) 10
this compiler 0 — the body is pure integer

And on the 32-bit side the control is the compile itself: pinned still refuses test/test_float_elem_address.pas outright (unsupported float operator / unsupported float binop) where this compiler runs it.

Verified

Five targets — native, i386, arm32, riscv32, aarch64 — every row correct: double stride 8, single stride 4, Int64 stride 8, whole-array 0, scalar 0, and float add / div / single mul / compare / int-operand / all exact.

xtensa took the same edit and is NOT verified — it does not run on this host, which is what the ticket already said about it.

Tests

gate.sh quick GREEN at eeb4cfd6cc2a.

Left open, deliberately

The 64-bit dispatch in the 32-bit backends has the same shape — Is64Bit(IntToTypeKind(IRTk[left])) — so @W[0] over an array of Int64 is still dispatched as a 64-bit operation on a 32-bit address. It produces the correct answer today (verified: stride 8 on all three), because the low word is right and the high word is discarded, so this is a cost question and not a correctness one. Changing it would alter a working path with no measured benefit; noting it here rather than folding it in silently.

frankA disagreed, measured it, and came back agreeing — and his numbers make this deferral evidence rather than a judgement call. His reason for looking is the right one: "it produces the correct answer today" is exactly the reassurance that failed for the float half, where x86-64 was correct by denormal luck for as long as the bug existed.

Conclusion, his: no measured benefit, effect in the wrong place, unproven safety — leave it. The thing to fix first if anyone revisits this is the instrument: a way to measure a single procedure's emitted size on a cross target would have made it a five-minute question instead of a rebuild-and-diff.

MEASURED 2026-08-31 (frankA), and the deferral holds — do not repeat this experiment. The reasoning above ("correct today") is the same reassurance that failed for the float half on x86-64, so it was worth constructing the cases the note does not cover.

Tried to break it, could not. Four shapes where the high word is not discarded and the full 64-bit value is observed — Int64(PtrUInt(@W[0])) read back via shr 32, a 64-bit difference of two element addresses, and a pointer comparison (Boolean result tk, so the operands drive the dispatch). Correct on x86_64, i386, arm32 and riscv32. The address is not merely truncated safely, it is correctly zero-extended into the pair — a stronger property than this note claimed.

Then the control, by disabling the change rather than changing the input. The parallel edit at ir_codegen_riscv32.inc:2248 (IRValueKind(left)/(right), matching the float dispatch two lines above), rebuilt, same sources through both compilers:

the path is live binaries differ; compiler determinism checked first (same compiler twice = byte-identical)
code size 249708B both — unchanged
where the effect lands first differing byte at offset 168827, the same offset for a 5-line and a 15-line program

A constant offset independent of user source means the delta is in RTL codegen, not at the address site the change is justified by. No size win, blast radius in the wrong place. Reverted, nothing pushed.

Limits on those numbers, since they cut against the confidence: code= is RTL-dominated (identical for both programs) and so is coarse here, and no finer instrument was available — these binaries carry no section headers, so readelf -S returns nothing. The broad riscv32 suites were not run against the changed compiler, so it is unproven-safe, not proven-unsafe.

If anyone revisits this, fix the instrument first: a way to measure one procedure's emitted size on a cross target turns this into a five-minute question instead of a rebuild-and-diff.

Log