← board

r.c[i] on a field array[1..3] addresses one element past the field

Repro (values are fpc -O- -Mobjfpc's)

type TR = record g1: Byte; c: array[1..3] of Byte; g2: Byte; end;
var r: TR; i: Integer;
begin
  r.g1 := 200; r.g2 := 201;
  for i := 1 to 3 do r.c[i] := 10 + i;
  writeln(r.c[1], r.c[2], r.c[3], ' guards ', r.g1, ' ', r.g2);
end.
FPC pxx
array[1..3], guards 11 12 13, 200 201 11 12 13, 200 13
raw memory across the record 200 11 12 13 201 200 0 11 12 13
array[5..7] 25 26 27, 100 101 SIGSEGV
array[-2..2], guards 8..12, 200 201 8..12, 9 201
class field array[-2..2] fine, Free returns corrupts the instance, Free SIGSEGVs
array[-1..1, -2..0] (2-D) correct correct
array[0..2] correct correct

SizeOf is right in every case, so the LAYOUT was never the problem — the index arithmetic was.

Mechanism — one arm of a double case

IRLowerAddress's AN_INDEX path reads the array's low bound out of Syms[base].ConstVal only when the base is an AN_IDENT. For an AN_FIELD base it left lo at 0 — and the comment sitting on the field range-check a few lines above says why nobody noticed: "the parser already normalised the index to 0-based". Nothing normalised it. It could not have: a 1-D array field's low bound was never stored anywhere. UFldArrDimLo is filled only under if fNDims >= 2, which is exactly why the 2-D row of the table above is the one that works.

Reads and writes shifted identically, so the field itself is self-consistent and only its NEIGHBOURS are wrong. That is how this survived a corpus full of array[1..N] record fields.

Fix

Verification

test/test_record_field_array_low_bound.pas: 53 assertions, every value FPC's own — guards on both sides of each array, the array's own values, raw memory across the record, constant and variable indices, a named array type, the 2-D arm that was already right, the low = 0 arm that must stay right, and a class instance freed at the end. Under the pinned binary it fails and then SIGSEGVs at Free; after the fix, 53/53.

Nineteen existing tests that use array[1..N] (range checks, cross-target, typed consts, for-in bounds, param low bounds) were run individually and match their recorded expectations.

Log

Follow-up, same day — a THIRD declaration site

The same FPC differential probe that found this then found the variant part still doing it: case Integer of 1: (arr: array[1..2] of SmallInt) overlaid the branch two bytes in, so v.arr[1] read the HIGH half of the 0-branch's first field (v.i := 65536 gave arr[1] = 1, arr[2] = 5 where FPC gives 0, 1).

ParseRecordVariantPart registers branch fields itself and was not touched by the first commit. The comment on its own array-bounds parse already said this — "this was the same concept's third copy" — which is the whole lesson: the first fix went to the two sites a grep for fNDims >= 2 found, and the site that never had the N-D code was invisible to that grep. The test now covers it; total ok moves 53 -> 58.