The constructor ladder is the fifth copy, and it asked neither question
How it was found, which is the part worth repeating
Not by auditing constructors. examples/tk/uses_tkinter_and_configparser failed
on arm32 with constructor with more than 4 parameter words not supported, so
the repro was a class with six Integer parameters. That fix took ten minutes.
Then the TEST was written to cover the boundary rather than the failure —
one word, three, four (the old limit), nine, and then an Int64 first parameter
so that the word count and the argument count stop being the same number. That
last row is the whole finding: a limit expressed in WORDS cannot be tested with
arguments that are all one word each.
It failed on arm32, on xtensa, and crashed on i386 — three defects nobody was looking for, from one row added on principle.
What was wrong
Five paths marshal a call on a 32-bit backend: direct, virtual, indirect,
external, and class instantiation. The first four ask Arg32Class. The
constructor arm pushed one word per argument on all four backends:
| target | before | symptom |
|---|---|---|
| i386 | 4 bytes/arg | SEGFAULT on Int64, record and Double alike |
| arm32 | 1 word/arg, and refused > 4 words | Create(70000,1,2,3,4) gave 4295039530 for 71234 |
| riscv32 | 1 word/arg, and refused > 8 words | refusal fired first |
| xtensa | 1 word/arg | 8589938036 / 702091 / 700001 for 71234 / 702039 / 702509 |
i386's crash has a mechanism worth stating: the ctor arm reloads Self from
[esp+argBytes-4] after the call, and argBytes was computed from the same
wrong count — so it read an ARGUMENT as the instance pointer and the caller used
it as one.
The fix
Each backend's ctor arm now asks Arg32Class and advances by Arg32Words,
emitting through the ladder that backend already uses elsewhere
(EmitCallArgWordsRISCV32 is literally the shared helper the other three
riscv32 paths call — the constructor was the fourth caller it never had). On
xtensa the even-word pad (XtensaPadTo64Xtensa) comes with it, because the
callee spill runs one shared 64-bit branch for Int64, Double and a 5..8 byte
record and skips an odd word on its side too.
The arity refusals go with them. arm32 over four words and riscv32 over eight now keep the whole pushed block live across the call — words past the registers ARE the callee's incoming stack arguments — and reload Self from its slot afterwards instead of saving a second copy. That is what the direct, virtual and indirect ladders in those same files already do.
Verification
test/test_ctor_arg_classes_and_arity.pas, rows from one word to nine plus one
row per multi-word class, passes on all six targets. Two-directional against
the pinned compiler:
x86-64 fails=0 WIDECTOR OK <- the control that must not change
i386 REFUSED (only ordinal/pointer parameters supported yet)
arm32 REFUSED (more than 4 parameter words)
riscv32 REFUSED (more than 8 parameter words)
xtensa fails=3 WIDECTOR FAILED
No regression in the neighbours: test_call_arg_marshalling_32bit's xtensa
mismatch rows are the same eight as the pinned baseline (its known Int64/Double/
set gaps on the virtual and indirect ladders, deliberately out of scope), and
test_cross_set_param, test_cross_virtual_indirect_aggret,
test_arm32_record_byval_wide and test_byvalue_record_param_every_call_shape
are green on all five cross targets.
What it says about the shape
Fourth instance in two days of the same thing: a decision that exists once as a
shared oracle, and one call path that does not consult it. Arg32Class was
extracted for exactly this and the extraction converted four paths, not five.
The next one to check is the EXTERNAL/cdecl arm on each backend.