← board

System.InitializeArray / FinalizeArray

The shape the corpus asks for

GetMem(PF, SizeOf(TFoo));
InitializeArray(PF, TypeInfo(TFoo), 1);   { runs TFoo.Initialize on PF^ }
...
FinalizeArray(PF, TypeInfo(TFoo), 1);     { runs TFoo.Finalize }
FreeMem(PF);

tmoperator2.pp:100, tmoperator3.pp:82, tmoperator9.pp:48 — three rows, one undefined variable (InitializeArray). grep finds neither name anywhere in compiler/ or lib/rtl.

Why the syntactic ticket cannot absorb this

[[feature-pascal-management-operators-nested-and-array]] generalises WrapManagementOpsRange from a symbol to an lvalue node it can see. These helpers are handed a pointer and a descriptor: the element type is a runtime value. No amount of desugaring reaches it. That ticket's own Sketch says so and names this one as the answer.

The two halves, and the second is the work

  1. The helpersInitializeArray(P, Info, N) / FinalizeArray walking N elements and calling through the descriptor. A loop; small.
  2. The descriptorTypeInfo(TRec) must carry a record's management-operator entry points. That is RTTI emission (rtti_emit.inc), and it is the half that decides what shape 1 can even have. Do not start with the helpers: a helper written against a descriptor that does not exist yet is a guess about a layout somebody else will choose.

What is NOT established

Each of the three rows stops at its FIRST InitializeArray. Everything those files assert afterwards is unverified — a compile that stops at line 100 says nothing about line 101. When the helpers land, re-measure all three rather than assuming they go green; two of them also exercise New/Dispose and class fields above the failing line, which pass today, and one exercises shapes below it that nobody has run.

Gate

make compiler/pascal26 (self-host fixedpoint), the three corpus rows diffed against fpc 3.2.2 including exit codes (they Halt(n) with distinct n per assertion, so an exit code names the row that failed), and tools/gate.sh quick.

2026-09-07 (frankA) — a FOURTH consumer, and it is not a corpus row

SetLength on a dynamic array of a record declaring Initialize/Finalize needs this descriptor, and it is the reason [[feature-pascal-management-operators-nested-and-array]] cannot close its dynamic arm in the parser.

Measured against fpc 3.2.2 (var d: array of TFoo, SetLength 3 -> 5 -> 2): Initialize runs INSIDE SetLength on the elements that come into existence, Finalize runs INSIDE SetLength on the elements that stop existing, and the survivors are finalized at scope exit. A scope-entry loop over Length(d) initializes zero elements and never sees one created later.

That makes the demand for this ticket broader than the three tmoperator rows: every dynamic array of a managed record in any program, not a testsuite shape. SetLength holds a pointer and an element size and nothing else, so the grow/shrink paths are exactly the "handed a pointer and a descriptor" case the summary above already names as unreachable by desugaring — the same argument, arriving from a second direction.

Unchanged by this note: start with the DESCRIPTOR half. A SetLength hook written against a descriptor that does not exist yet is the same guess as a helper written against one.

2026-09-07 (frankA) — the descriptor half, measured before designing: most of it already exists

Claimed and read. The ticket says the descriptor is the half that decides what shape the helpers can have, and warns against writing a helper against a layout somebody else will choose. So: what is already chosen.

There IS a record layout descriptor, and a runtime that walks it

EmitLayoutRTTI (compiler/rtti_emit.inc) emits one per record, UClsRTTIOff[ci], and TypeInfo(TRec)'s DataPtr already points at it (the TYPEINFO_REQ_CAT_RECORD arm, rtti_emit.inc:1305ff). Format, read off the emitter:

+0   Int32   Kind = 1 (Record)
+4   Int32   Size
+8   Int32   MemberCount
+12  member[MemberCount], 16 bytes: { Offset, Kind, ArrCount, TypeRef }
     dyn[dCount],         20 bytes: { Kind=2, ElSize, Depth, BaseKind, BaseTypeRef }

And the runtime entry points the ticket describes as needing to be written already exist in compiler/builtin/builtinheap.pas:

So InitializeArray(P, TypeInfo(TFoo), N) is close to a loop over PXXRecordInitialize(P + i * Size, GetTypeData(ti)), with Size already in the descriptor. That is a smaller job than the ticket's summary implies, and it is still not the whole job, because of what the descriptor does NOT carry.

What is missing, and the two constraints that shape it

1. The management-operator entry points are not in the descriptor at all. PXXRecordInitialize zeroes managed members; it does not and cannot run a record's class operator Initialize, because nothing in the blob names it. That is the field this ticket exists to add.

2. A record with operators but NO managed fields gets NO descriptor. Pass 1 is guarded by RecordHasManagedFields(ci + REC_UCLASS_BASE). TFoo = record n: Integer; class operator Initialize; end is exactly that shape and it is the shape every fixture in the management-operator family uses. The emission condition has to widen, or the feature is invisible on its own test cases.

3. The header is 12 bytes of Int32, so members start 4 mod 8. A pointer slot cannot go at +12 on a 64-bit target. Either the header grows to 16 (members move to +16) or the operator pointers live at the end of the blob.

The consumer census, because a header change moves every member walk

Enumerated rather than estimated. Emitter side, compiler/rtti_emit.inc: six sites — DataPutZeros(12 + mCount*16 + dCount*20) ×2, mHdr := hdr + 12 + k*16 ×2, dynDescOff := hdr + 12 + mCount*16 + dynIdx*20 ×2. Runtime side, compiler/builtin/builtinheap.pas: memberPtr := Int64(desc) + 12 in PXXRecordZeroManaged, PXXRecordRetain, PXXRecordRetainIntf, PXXRecordReleaseIntf, PXXRecordRelease and PXXClassFinalize. Nowhere else in the tree.

THE TRAP, AND IT WOULD BE SILENT. Int64(desc) + 12 occurs EIGHT times in builtinheap.pas and only SIX of them are this descriptor. The other two are baseKind := PInt32(Int64(desc) + 12)^ in PXXDynArrayRelease and PXXDynSetLen, which walk the dyn-array descriptor (Kind = 2), where +8 is Depth and +12 is BaseKind. One literal, two blobs, one file. A blind sed 's/desc) + 12/desc) + 16/' corrupts every dynamic-array release in the RTL and nothing about it looks wrong. The discriminator is the assignment target (memberPtr vs baseKind), not the constant.

Also NOT to be changed: subDesc := Pointer(memberPtr + 12 + typeRef) inside the member loop. That 12 is the offset of the member's own TypeRef slot and the reference is self-relative, so a header resize does not touch it.

The shape I propose, and the one I reject

Propose: grow the header to 16 — +12 Int32 Flags — members at +16, and put the operator pointers at the END of the blob, 8-aligned, with a bit in Flags saying they are there. Twelve sites change by one constant each, the 16-byte member stride and the self-relative subDesc are untouched, and an old consumer reading Flags as zero behaves exactly as today.

Reject: a new Kind = 3 for "record with operators". It doubles the dispatch in precisely the six routines that must never get a record's kind wrong, to save a header word.

Not yet measured, and it gates the first line of code

Whether DataPutZeros/PatchDataI32 and the fixup pass can place an 8-aligned pointer at a computed tail offset, and what AddDataPtrFix needs to relocate a PROCEDURE address rather than a data address — every existing AddDataPtrFix call in this emitter points at Data[] or a string, not at code. That is the next reading, and until it is taken, the layout above is a proposal and not a decision.

2026-09-07 (frankA) — the two readings taken, and the proposed layout is WITHDRAWN

Both open readings are closed. The first answers yes; the second retires the header-resize proposal above and replaces it, so read the previous section as history.

1. A procedure address CAN be relocated into Data[], and DCE knows

Not AddDataPtrFixcompiler/emit.inc:109 says in its own words that it is "a data->data 8-byte pointer relocation". The recorder that does this is AddMethodFix(dataPos, procIdx) (compiler/emit.inc:299): "Record a code-address relocation into Data[]: the 8-byte slot at dataPos is patched to the entry address of Procs[procIdx]. Every VMT slot and every RTTI method entry goes through here. This is the ONE append point." It resolves in elfwriter.inc:1802 as addr := entry + Procs[MethodFixups[i].ProcIdx].BodyAddr.

Two properties that matter and were checked rather than assumed:

The kind-3 self-relative typeRef is anchored on mHdr + 12 at all four sites (1479, 1531, 1628, 1671), matching the runtime's Pointer(memberPtr + 12 + typeRef), so it is a property of the MEMBER and no header question touches it.

2. The header resize is unsafe, and the reason is the BOOTSTRAP

The proposal above — Flags at +12, members at +16 — would work fine if the emitter and the runtime always shipped together. They do not. Every make compiler/pascal26 runs the OLD emitter against the NEW runtime: the seed binary writes stage-1's Data[] with its own EmitLayoutRTTI while linking stage-1's builtinheap.pas out of the working tree. No seed escapes it — the pin included, because a seed invoked as ./compiler/pascal26 resolves bdir to the live compiler/builtin/ either way (pasparser_proc.inc:4521).

So stage-1 would read its own descriptors from +16 when they were written at +12, and the skew is not benign: memberPtr starts at member0 + 4, so the walk takes member0.Kind as an Offset, member0.ArrCount as a Kind and member0.TypeRef — a self-relative delta — as the array count, then loops that many times calling PXXStrDecRef on recAddr + garbage. In a compiler, with no diagnostic.

Measured, not reasoned (2026-09-07, a counter behind an env gate in EmitLayoutRTTI): compiling compiler/compiler.pas emits 19 record layout descriptors, 1–3 members each. Stage-1 walks them. The window is real.

The replacement: a new MEMBER kind, not a header field

Announce the operators as a member entry with a new Kind and ArrCount 0, its TypeRef self-relative to the operator table at the tail of the blob.

This is not the Kind = 3 rejected above: that was the blob's kind at +0, where a second value really would double the dispatch in the six walks. This is the member's kind at member+4, a field that already carries seven values and is plainly the format's extension point.

It has no bootstrap window at all. ArrCount = 0 makes the entry inert in every one of the six walks — each is while j < arrayCount around a case kind of with no else arm, so the body never executes — which means an old runtime walking a new blob does nothing with it and a new runtime walking an old blob never sees one. Both directions safe, which is what a self-hosting build requires and what a header field cannot give.

PXXRecordZeroManaged computes subDesc := Pointer(memberPtr + 12 + typeRef) before the kind test, unconditionally — checked, and it never dereferences it except under kind = 3, so a tail-pointing TypeRef is safe there too.

Landed now, as the part that is verifiable on its own

PENDING-COMMIT:

The values are unchanged and the proof is byte-identitysha256(compiler/pascal26) is a359aa9be0373666 before and after all twelve substitutions, so the spelling change provably emits the same compiler.

The control for the new fixture, because its printed counts cannot see the defect it is for: with MemberCount emitted as zero behind an env gate, the counts stayed byte-identical at 1000/1000 and the census went from live=8 to live=9392. The leak row is the assertion that reads the descriptor; the value row is not.

What is still not measured

Whether DataPutZeros and the tail-offset arithmetic can place an 8-aligned pointer table after a dCount * 20 region — the blob is currently 4-aligned throughout and nothing in it is a pointer today. AddMethodFix patches an 8-byte slot; whether it requires the slot to be aligned, or the ELF writer only needs it in range, is the next reading and it gates the emitter change.

2026-09-07 (frankA) — a correction to the section above, and the release consequence

Correction first. The section above says "no seed escapes it — the pin included, because a seed invoked as ./compiler/pascal26 resolves bdir to the live compiler/builtin/ either way." The "either way" was not measured and is wrong. Run in place, stable_linux_amd64/default/pinned --where reports stable_linux_amd64/default/builtin/ — its own snapshot — which is precisely why the pinned builds live lib/rtl gate row is a coherent pair.

The conclusion survives on a different fact: seeding from the pin means COPYING it to compiler/pascal26 (the recovery line in the $(COMPILER_STAMP) recipe spells that out), and from there its bdir is the live tree like any other seed. So the pin is no escape because of where a seed goes, not because of what the pinned binary does. Landed at 30ed522b3 with the over-broad wording; corrected here and in both copies in the tree.

The FPC bootstrap chain does NOT hit it, and that is the release problem

make bootstrap is fpc → FPC_COMPILER → BUILD_COMPILER → VERIFY_COMPILER. Every stage carries the new emitter. Every stage also links the live compiler/builtin/: a $(PXX_TMP)-located binary finds no builtin dir beside itself — --where prints [MISSING] for it — and falls through to the CWD-relative last resort in ParseUsesUnitBody, with make's CWD at the repo root. Measured with its own control: that binary compiles from the repo root and answers unit source not found: builtinheap from anywhere else.

So under a header change:

chain emitter runtime coherent? its own check
make bootstrap (fpc-seeded) new new yes cmp BUILD VERIFY passes
make compiler/pascal26 (locally seeded) old new no prints converged

Both pass their own checks and both binaries self-reproduce. The single instrument that would see the difference is the property the two chains exist to compare — a bootstrap from fpc 3.2.2 is byte-identical to the pin-derived binary. That is the release's anti-impersonation claim: a stranger who does not trust our pin can rebuild from FPC and compare shas. A descriptor-format skew lives exactly in the gap between the two chains, so it would arrive as a release-blocking sha difference with nothing else red, produced by a compiler that was corrupting its own memory while emitting the artefact.

This is a second, independent reason the extension must be a new member kind rather than a header field, and it is the one that outranks the first.

2026-09-07 (frankA) — the alignment reading, taken; it removes the last unknown

The section above named this as gating the emitter change: whether an 8-aligned pointer can be placed at a computed tail offset, and whether AddMethodFix needs its slot aligned. Four readings, and the answer changes the shape.

1. Emission has no alignment requirement at all. PatchDataU64 (elfwriter.inc:65) stores byte-at-a-time into Data[], so a code slot can sit at any offset as far as the writer is concerned.

2. A code slot in Data[] is 8 BYTES ON EVERY TARGET, 32-bit included. The i386/arm32 writer says so in its own words (elfwriter.inc:2833): ".rela.data: data->data pointers and data->code method slots. Slots are 8 bytes wide (layout shared with 64-bit targets); the reloc patches the low word, the high word stays zero." So an operator entry is 8 bytes everywhere and the runtime reads it as PMachineWord(slot)^ — the full word on 64-bit, the low word on 32-bit, which is the same byte on every little-endian target we have. No per-target width branch is needed and none should be written.

3. The tail offset is NOT reliably 8-aligned, so the proposed shape was wrong. 12 + mCount*16 + dCount*20 is (4 + 4*dCount) mod 8 — 8-aligned only when dCount is odd — and the blob's own base is a bare UClsRTTIOff[ci] := DataLen with no padding, so even a corrected tail offset would not land on an aligned absolute address.

4. But aligning a blob is a one-liner and already the house idiom. while (DataLen mod 8) <> 0 do DataPutB(0); appears in emit.inc (twice), elfwriter.inc, resources_emit.inc and ir.inc.

What this changes

Do not put the operator table at the tail of the descriptor. Emit it as its OWN blob, 8-aligned at its own emission point, and point the operator member's TypeRef at it self-relatively — exactly the mechanism a kind-2 member already uses for its dyn descriptor and a kind-3 member for its sub-descriptor.

That is strictly better than the tail, and not only for alignment:

Nothing is now unmeasured. AddMethodFix relocates a procedure address into Data[] and DCE keeps the target alive; a slot is 8 bytes on every target; alignment is free at the emission point; and the announcement is a new member kind, which is the only shape the bootstrap admits. The emitter change is unblocked.

2026-09-07 (frankA, from frankS) — my "loud, not silent" control was drawn from the wrong population

The section above says the CWD-relative builtin fallback fails loudly from anywhere but the repo root, "measured with its own control". The measurement was real and the control was wrong, and frankS caught it.

I measured from a SCRATCH directory. A scratch directory has no compiler/builtin/, so of course the lookup fails. The population that matters is directories that DO have one — ls -d /home/neo/*/compiler/builtin answers twenty on this box. Confirmed here with a controlled copy rather than taken from the report:

CWD result
no compiler/ at all loudunit source not found: builtinheap
a sibling checkout SILENT — that tree's builtinheap.pas is parsed
the repo root correct

Proved by poisoning the copy with an unresolvable identifier in a real declaration; the diagnostic named it. frankS's first attempt appended text after the unit's end. and the build succeeded — nothing parses that, so it was a probe that could not fail, and it read as support for my claim.

So my elimination argument for frankS's baseline is void. I argued that two matching shas prove builtin resolved correctly because a wrong CWD yields no binary. It yields a binary from any sibling checkout. What actually establishes their CWD is their own evidence: every stage passed the RELATIVE path compiler/compiler.pas, which resolves from nowhere else, and the builds succeeded. The baseline stands on frankS's evidence, not on mine.

And this lands on the descriptor work directly

frankS built from a sibling checkout's root and got a binary byte-identical to the repo-root one — which reads as proof that CWD does not matter. It is not. The two builtinheap.pas differed only by PXX_REC_DESC_HDR = 12 against the bare literal 12 — the rename landed at 30ed522b3 — and that is semantically identical, so the bytes agree either way. The expected value collided with the failure value and the probe could not have failed that day.

A record-descriptor FORMAT change is exactly what separates them. So the defect class that is visible ONLY to the bootstrap-vs-pin comparison is also the class that would make that comparison LIE if it were run from a sibling checkout. Not an argument against the finding — the instrument is sound and its precondition is not self-evident. Whoever runs that comparison against the operator-emitter change must state the CWD they ran it from.