← board

Metaclass construction WITH ARGUMENTS segfaults on every non-x86-64 target

Symptom

Constructing through a class reference works only when the constructor takes NO arguments:

type
  TB  = class F: string; constructor Create(const n: string); virtual; end;
  TBC = class of TB;
  TD  = class(TB) constructor Create(const n: string); override; end;
var tc: TBC; o: TB;
begin
  tc := TD;
  o := tc.Create('x');      { SEGFAULT on i386 / aarch64 / arm32 / riscv32 }
  writeln(o.F);             { correct on x86-64: 'd:x' }
end.

Measured 2026-07-13:

target tc.Create (no args) tc.Create(arg)
x86-64 ok ok
i386 ok segfault
aarch64 ok segfault
arm32 ok segfault
riscv32 segfault segfault

So the dispatch (allocate the dynamic class, call its virtual constructor) is right everywhere except riscv32; it is the ARGUMENT MARSHALLING through AN_METACLASS_NEW that is x86-64-only. riscv32 fails a step earlier and needs its own look.

Why it matters

This is the shape a class FACTORY takes — GetClass(name) → construct → configure — so it is on the path for streaming/.lfm and for any registry-driven construction. fpcunit's suite builder hits it directly (tc.CreateWith(ml[i], SN)), which is how it surfaced; the fpcunit chain is green on x86-64 and would fault the moment it ran cross.

test/test_typed_metaclass_b278.pas covers the x86-64 behaviour and is deliberately NOT in any cross list until this is fixed — see the note in that file.

Where to look

AN_METACLASS_NEW in ir.inc (BuildMetaclassNew builds it) and its per-backend emit. The no-arg case working everywhere but x86-64 says the args are being placed by an x86-64-shaped assumption (register order / the hidden Self slot ordering relative to the allocated instance), so compare against how AN_CALL marshals a normal constructor's args on each backend — that path is known good.

Gate

make test + self-host byte-identical + cross (this IS the cross gate).

2026-07-13 — FIXED. It was never target-specific: it was ARGUMENT-specific.

Green on all five targets (x86-64, i386, aarch64, arm32, riscv32).

AN_METACLASS_NEW lowered its constructor arguments with a raw IRLowerAST. Every other call path — AN_CALL, AN_VIRTUAL_CALL, AN_INTF_CALL — uses IRLowerCallArg, which is PARAMETER-AWARE: it lowers the expression against the DECLARED TYPE of the parameter it is being passed to (and allocates the hidden managed-string temp when one is needed). A raw IRLowerAST only knows the expression.

For an Integer argument the two agree, so it worked. For a const s: string they do not, and x86-64's IR_ARG emit for a frozen string happened to forgive the difference while the other four backends did not — which is exactly what made this look like a cross-target codegen bug rather than a missing call-lowering step. An Integer argument had been working on every target the whole time; nobody had tried a string.

The lesson generalises: a hand-built IR_ARG chain that does not go through IRLowerCallArg is wrong, and it will look target-specific because only some backends notice.

Fix: use IRLowerCallArg + the managed-temp block, the same as the other three call paths. test/test_typed_metaclass_b278.pas now passes on all four cross targets and the x86-64-only note has been removed from it.

Residual (SEPARATE bug, split out)

While confirming this, riscv32 turned out to have an unrelated defect that this ticket's reproduction was accidentally also hitting: storing a string LITERAL into a class field gives an empty string there. It has nothing to do with metaclasses (a direct TD.Create shows it too). Filed as [[bug-riscv32-string-literal-to-class-field]].

Log