← board

A record field array[..] of string[N] drops N and overruns into the next field

Measured, against FPC 3.2.2

Five shapes of the same type, one wrong:

type TA = array[0..1] of string[8];
the array is a… FPC pxx
global g: TA abcdefgh (8) abcdefgh (8)
local lv: array[0..1] of string[8] abcdefgh (8) abcdefgh (8)
by-value parameter abcdefgh (8) abcdefgh (8)
function result abcdefgh (8) abcdefgh (8)
record FIELD r.a[0] abcdefgh (8) abcdefghijkl (12)

And it is the ARRAY ELEMENT's capacity specifically, not the field path in general — a plain frozen field is fine:

field shape FPC pxx
record s: string[8] end abcdefgh (8) abcdefgh (8)
record a: TA end (named alias) abcdefgh (8) abcdefghijkl (12)
record a: array[0..1] of string[8] end (inline) abcdefgh (8) abcdefghijkl (12)

The corruption

type TR = record a: array[0..1] of string[8]; tail: Integer; end;
var r: TR;
begin
  r.tail := 12345;
  r.a[1] := 'ZZZ';
  r.a[0] := 'abcdefghijklmnopqrstuvwxyz';   { 26 chars into a cap-8 element }
read back FPC pxx
r.a[0] abcdefgh abcdefghijklmnopqrstuvwxyz
r.a[1] ZZZ ZZZ
r.tail 12345 31353

The store runs past the field and rewrites the neighbouring Integer. No error, no bounds complaint. r.a[1] surviving while tail is clobbered is recorded as measured — do not build a layout theory on it without a probe; the point here is the observable, which is that a declared string[8] accepted 26 characters and the damage landed outside the array.

Cause — suspected, NOT confirmed. Probe before recording one.

This has the exact shape of the LastType* staleness class documented at the head of the LastType* block in defs.inc: a per-entity carrier that some declaration paths capture and one does not. ArrTypeElemStrCap exists precisely to carry an array's frozen-string element capacity across the gap between where string[N] is parsed and where the array type is used, and its own comment says so. The field path appears not to consult it — there is no UFld twin of it in the field-carrier family, where UFldPtrElemTk, UFldEnumId and UFldStrElemTk all exist.

Confirm with PXXDBG and by checking whether AddUField ever sees the element capacity, before writing a cause into this ticket. Every wrong root cause in this repo was a plausible story nobody diffed against an oracle.

Where the fix goes

Most likely a UFldElemStrCap carrier beside the existing UFld* element carriers — defs.inc and symtab.inc are Track A, and the write site at the field declaration is in pasparser_decl.inc, which is Track P's file. Coordinate if two agents hold those concurrently. If the fix turns out to be entirely inside the parser, re-file it as Track P.

Why it was not found before

The four shapes that work are the four anyone writes a test for. The record field is the one that needs the value to survive two hops — string[N] into the array type, then the array type into the field — and it is the only shape where both hops happen.

Gate

make compiler/pascal26 + the three programs above matching FPC on every row, r.tail included.

RESOLVED 2026-08-30 (frankwasm) — a sibling arm that was never written

Cause, confirmed rather than suspected. The suspicion in this ticket was wrong in an instructive way: I guessed a missing UFld twin of ArrTypeElemStrCap, i.e. that the DECLARATION dropped the capacity. It does not. SizeOf proves the layout knows it — a record of cap-8 elements is 40 bytes and the identical record of cap-200 elements is 424, a difference of exactly 2x192. The element slots are sized by the declared N, so UFldStrCap holds 8 and every carrier on the declaration side is intact.

The defect is in the STORE clamp, ir.inc AN_ASSIGN, which had exactly two arms:

r.s   := s      AN_FIELD                  -> RecFieldStrCap    (present)
a[0]  := s      AN_INDEX over AN_IDENT    -> SymStrCap         (present)
r.a[0]:= s      AN_INDEX over AN_FIELD    -> nothing           MISSING

So lhsStrCap stayed 0 and the copy took the SOURCE length. The neighbour of a record field being the next field is why it presented as corruption rather than a long string.

The arm that exists carries a comment describing this exact bug"Without it a[0] := s copied the SOURCE length — writing past the element's N chars into its neighbour" — written when the SYMBOL case was fixed. The field case was not grepped for. That is precisely the rule in CLAUDE.md and devdocs/dev/normalise-dont-special-case.md: if you fix a bug on one arm of a double case, grep for the sibling before closing the ticket. This ticket is the cost of not doing it.

Fixed by asking the question in one place, not by adding a third arm: FrozenStrElemCapOf(base) in ir.inc answers "the frozen-string element capacity of this index base" for both an array SYMBOL and an array FIELD, and the single AN_INDEX arm calls it. A third parallel arm would have been the same mistake a third time.

Two more broken shapes fell out of the one fix, both confirmed broken on pinned and correct after — neither was in the original report:

o.inner.a[0] := LONG    nested record field
ar[0].a[0]   := LONG    field of an array-of-record

A dynamic array of string[N] was already correct and stays so.

Regression test: test/test_frozen_str_array_elem_cap.pas, covering all eleven shapes including the four that always worked (so a future fix cannot regress them) and the corruption assertion itself — tail reading 12345. Its .expected was generated from FPC, then diffed against our output; it is an oracle file, not a recording of what we happen to print.

Gate: make compiler/pascal26 converged in 1 round (8a9e78fb4555); all four differential programs match FPC.

Log