← board

An 8-byte by-value record loses its second word through virtual and indirect calls, on every 32-bit backend

Found while building the gate for [[feature-a-i386-refuses-a-by-value-record-parameter-on-the-internal-convention-so-lib-rtl-image-does-not-build]]. That ticket is about i386 REFUSING a by-value record. Writing a test that covers all three call shapes rather than only the direct one turned up something wider and worse: three backends do not refuse, they get it wrong.

test/test_byvalue_record_param_every_call_shape.pas, at 995b1daef:

x86-64   OK
aarch64  OK
i386     REFUSED  (only ordinal/pointer parameters supported yet)
arm32    FAILED   virtual 8-byte 7061 want 7035 | override 7601 want 7305 | indirect 7060 want 7035
riscv32  FAILED   virtual 8-byte 7059 want 7035 | override 7616 want 7305 | indirect 7060 want 7035
xtensa   FAILED   virtual 8-byte 7060 want 7035 | override 7616 want 7305 | indirect 7051 want 7035

Exit code 0 on all three. Nothing crashes.

Cause, and it is one line missing in a shared oracle

Arg32Class (compiler/symtab.inc) classifies a by-value call argument for all four 32-bit backends. Its ladder handles tySet, tyInt64/tyUInt64, tyDouble, tySingle — and not tyRecord, which therefore falls through to the A32_WORD default: one word.

ParamValueSize gives a by-value record a slot of RecSize rounded up to the pointer size, so on a 32-bit target an 8-byte record's slot is EIGHT bytes and the callee reads two words. The caller pushes one. The second word is whatever was next on the stack.

That also explains the exact boundaries, and they are why this survived:

record size classified slot agrees?
4 bytes A32_WORD (1 word) 4 yes, accidentally
5..8 bytes A32_WORD (1 word) 8 NO
> 8 bytes frontend sets IsRef, so one word = the address 4 yes

So the defect is confined to a size band with a correct case on either side of it, which is the shape that defeats a spot check.

Why only virtual and indirect

Each backend's DIRECT call path has its own hand-rolled ladder that handles records; only the virtual and indirect paths go through Arg32Class. This is the third time that split has produced a bug — ir_codegen386.inc records the by-value SET case arriving in one ladder and not the others, where pS(1, [eA,eC,eD], 9) through a proc-var answered 838829819 instead of 139.

What to build

Add A32_RECORD to Arg32Class and an emit arm to the virtual AND indirect ladders of all four 32-bit backends. The arm is the one the SET case already uses: the value register holds the record's ADDRESS, so push ceil(RecSize/4) words from it, high first, so word 0 lands lowest.

The comment at ir_codegen386.inc:3936 argues against exactly this"a new class would silently reach two backends with no emit arm for it" — and it was right at the time. The answer is to write the arms, not to keep asking at one site: the site-local question is what left the other three backends wrong.

Count the backends before closing. Seven exist; four are 32-bit and all four are affected, i386 by refusal rather than by corruption.

Gate

test/test_byvalue_record_param_every_call_shape.pas passing on all six targets. It already discriminates: pre-fix x86-64 and aarch64 PASS, so a change that merely makes everything green without those staying green has broken something else.

2026-09-02 — closed after re-verifying, not from the commit log

Re-ran the guard on every runnable target at c4ec910f5, rather than closing on the presence of a commit: x86-64, i386, arm32, riscv32, aarch64 and xtensa all answer BYVALRECPARAM OK. 567577507 confirmed on origin/master with git merge-base --is-ancestor, which asks about the branch rather than about this checkout's object store.

Worth carrying from the fix: the test was written for the BOUNDARY (a row whose word count differs from its argument count) rather than for the reported failure, and that is what turned a ten-minute arm32 fix into four defects found across four backends.