← board

The fact

type TNotify = procedure of object;
     TM = record Code, Data: Pointer; end;
WriteLn(SizeOf(TM), ' ', SizeOf(TNotify));
target record Code, Data: Pointer procedure of object
x86-64 16 16 correct
i386 8 16 wrong — measured by running
arm32 8 16 wrong — measured under qemu
wasm32 8 16 wrong — measured under node
riscv32, xtensa same code path, not run

A user-declared two-pointer record sizes correctly on a 32-bit target. The built-in method-pointer record does not.

Root cause

compiler/symtab.inc, EnsureMethodPtrRec:

UClsSize_[ci]    := 16;
UClsAlign[ci]    := 8;

Both constants are the 64-bit values, minted once and used for every target. The doc comment above them says "the shared 16-byte method-pointer layout {Code@0, Data@8}", which is a second copy of the same assumption in prose.

What is NOT wrong, checked rather than assumed

The layout is target-aware; only the declared size is not. On i386 a real method pointer read back through a TM cast returns the object in Data — i.e. the store used offset 4, not the offset 8 the comment claims:

--- i386 ---
via cast: code<>0=TRUE data=obj=TRUE
hi

So this does not corrupt anything and does not misdirect a call. The temp is allocated at the size the compiler believes, so the over-sized zeroing stays inside its own allocation. What it costs is 8 wasted bytes per method-pointer value on every 32-bit target, and a SizeOf that a program can read and that disagrees with both the real layout and FPC.

prio 20 on that basis: observable, cheap to fix, harms nothing today. Please do not read the low number as "not real" — it is measured, on three targets, by running.

Fix

Size and alignment from the target's pointer size rather than from a literal, and correct the comment in the same edit — it states Data@8 as fact and is wrong on five of the seven targets. (devdocs/dev/root-cause-over-microfix.md and the note that a deletion has copies too: the prose is the second copy here, and the one that would survive a correct code fix.)

How it surfaced, which is the part worth keeping

Falsifying the new wasm32 IR_DEFAULT_MEM arm. One break — halving the zeroed byte count — did not turn the check red. The tempting reading was "the check is weak". The real reason is this ticket: the record declares 16 bytes where the 32-bit payload is 8, so half of 16 still covers all of it. A break that a check cannot see and a break that changes nothing observable look identical from the check's output, and only chasing the second one turned up a real defect two files away.

What would raise this above p20

Added by the coordinator 2026-08-28, at the filer's request that the low number not be read as "not real". The p20 is honest today because the cost is eight wasted bytes per value on 32-bit and a SizeOf a program can read back wrong — no corruption, and the store already uses the target's pointer size.

It stops being p20 the moment a 32-bit record layout has to match a binary this compiler did not produce: interop with an FPC-built unit, a serialized struct read back by another toolchain, or any on-disk/on-wire format containing a procedure of object. There, eight bytes of over-declaration is a field-offset mismatch rather than waste. Nobody has hit that; if anyone does, this is not a p20 and the ticket should say so rather than being re-argued from scratch.

Fixed 2026-09-02

EnsureMethodPtrRec now takes 2 * TARGET_PTR_SIZE and TARGET_PTR_SIZE. riscv32 measured too — this ticket listed it as "same code path, not run", and it was 16 there as well; so were i386 and arm32.

target ptr record Code, Data: Pointer procedure of object
x86-64 8 16 16 (unchanged)
i386, arm32, riscv32 4 8 16 -> 8

All THREE copies of the prose corrected in the same edit, as this ticket asked: the EnsureMethodPtrRec header, MethodPtrRecId's comment in defs.inc, and the method-call lowering comment in ir.inc — each stated {Code@0, Data@8} as fact, which is wrong on five of seven targets. The ticket's own note that the prose is the second copy, and the one that would survive a correct code fix was right, and there were three.

Verified

test/test_method_pointer_size_is_two_pointers.pas. Assertions are RELATIONAL — the method pointer must equal the hand-written record Code, Data: Pointer and both must equal 2 * SizeOf(Pointer) — so the file carries no expected widths and passes on every target while printing a different correct number on each. Green on x86-64, i386, arm32 and riscv32.

It also CALLS. A size row alone cannot see the failure a size change would introduce: shrinking the record is only correct if the call path never relied on the old width. The test assigns a method pointer, calls through it, and checks the receiver saw its own object — with TWO receivers, so a Data read at the wrong offset cannot pass by landing on the only object present.

Positive control against the pinned compiler:

Pin precondition asserted, not assumed: git diff HEAD -- lib/ is empty, so the change touches nothing the pinned binary reads live (a pin freezes the BINARY, not lib/rtl or lib/crtl).

gate.sh quick GREEN, FPC seed canary PASS.

One trap worth recording. The test first printed FAIL lines and exited 0, so under the pinned/i386 control it reported failure in the text and success in the exit code — and testmgr reads the exit code. A test that cannot fail in the dimension the harness reads is not a test. Halt(fails) added. The same omission was in the rows added to test_sizeof_user_name_shadows_builtin.pas earlier the same day, and is fixed there too. No .expected for either: their correct output is target-dependent.

Log