← board

The refusal, and why it is where it is

IRRecordIsFieldwiseComparable in compiler/ir.inc exits on UFldIsArray[fbase + i]. Everything else about the expansion already works for these records: the scalar members compare correctly, the nested-record recursion is in place, and the overlap test would still refuse a union.

So the change is bounded — one arm in the decide pass and one in the emitter — and the work is in the two questions the scalar path did not have to answer.

Element stride

A scalar member's span comes from TypeStorageSize(kind). An array member's does not: UFldTk holds the ELEMENT kind for an array field (its own comment says so, and UFldSemId's comment says a reader meaning "this field's VALUE is an enum" must check not UFldIsArray first). UFldArrLen gives the count for the one-dimensional case; UFldArrNDims / UFldArrDimLo / UFldArrDimSpan carry the rest. A frozen-string member has an array-shaped storage that is NOT this shape and must stay refused.

RecFieldCmpSpan currently answers a conservative 0 for an array, which makes the OVERLAP test refuse the whole record rather than mis-measure it. Whatever computes the stride has to feed that function too, or a union containing an array becomes invisible to the overlap test — which is the segfault case.

Unroll or loop

Unrolling is what the current emitter does for everything, and it is free for array[0..3]. It is not free for array[0..1023] of Byte, which would emit 1024 loads, 1024 compares and 1023 ands inline, per comparison site.

A loop needs a counter, a label pair and an early exit — none of which the expansion currently builds — and it changes the shape from "a value tree" to "statements plus a result", which is a bigger change to the arm than the array support itself. A stride-scaled IR_INDEX plus an unroll CAP, with the record falling back to the old path above the cap, is the smaller answer and keeps the "a refusal preserves the old answer" property this family relies on.

A cap needs its failure point measured, not chosen — sweep the emitted code size against the element count and put the cap where it stops being free, rather than at a round number.

Acceptance

record n: Integer; v: array[0..3] of Integer end, same n, v[3] differing: expected FALSE on all five targets. It answers TRUE on four and FALSE on i386 today, which is the same split the parent ticket's row 2 had — so the row is a real positive control against the current binary and not only against the pin.

Fixed 2026-09-07 — array members are unrolled, and the cap announces itself

RecFieldCmpElemSize / RecFieldCmpCount size an array member the way ABISysVWalkRec (abi.inc) already sizes one for the ABI, rather than deriving a second answer for the same tables. IRRecFieldwiseEqChain unrolls one comparison per element at those offsets, recursing for a record element.

UFldArrLen is the FLAT element count, read off the code that writes it (pasparser_decl.inc multiplies arrLen by each tdDimSpan), not inferred from a size that happened to match. A first-dimension-only count would have compared 3 of a 2-D array's 12 elements and answered a wrong TRUE — the same defect one level down — so the fixture's 2d last row varies the LAST cell.

IRRecordIsFieldwiseComparable became RecordFieldwiseCmpLeaves, returning the leaf COUNT instead of a Boolean: the caller needs both answers and a count from a second walk is a second chance to disagree with the first.

Measured on five targets

The acceptance row and more. Before: lastelem answered T on x86-64, aarch64, arm32, riscv32 and F-for-everything on i386 — and firstel (the FIRST element differing) also answered T on arm32 and riscv32, which this ticket did not predict: the compared word is n plus padding, so on a 32-bit target the array is entirely outside it. After: 20 of 20 cells correct on all five.

Also correct on all five: a 2-D array[0..2, 0..3] (12 cells, discriminated at the last), an array[0..2] of two-field records, an array[0..2] of AnsiString (content, not handle), and array[0..7] of Char.

arr[Idx(0)] = arr[Idx(1)] still calls Idx twice, not once per element, with 9 leaves per operand. 200000 comparisons of a record holding an array[0..2] of AnsiString return the right answer every time and both strings read back intact under -dPXX_HEAP_DEBUG, so a borrowed element is neither released nor retained.

The cap is a BUDGET, and it says so out loud

The sweep the ticket asked for found no knee: 55 bytes of emitted code per leaf, flat from 100 leaves to 1280 (x86-64, default -O, 20 comparison sites per build to clear the 4096-byte quantum code= reports in). Linear with no point where it stops costing, so no element count is the "right" one. REC_CMP_UNROLL_MAX = 64 is ~3.5KB per comparison site and covers the shapes that occur, without letting one = on array[0..65535] of Byte emit 3.6MB.

The first version of that sweep read .text out of --emit-obj objects and reported 64 bytes at every element count — the exported symbol was the only thing emitted, so the number could not move. The rows above are from executables, where it does.

Above the cap the compiler now WARNS, naming the record's leaf count and the cap. That is the part that matters: above the cap a = b is still wrong, and nothing in the source says which side of the line a record is on — adding one element to an unrelated member moves it. A silent cliff in CORRECTNESS is worse than either answer. Not an error: those programs compile today and some are accidentally right, and breaking them to protect the others is the trade this repo's guidance refuses.

Residual — and raising the cap is NOT the fix

An array of a scalar has no interior padding, which is exactly what makes a byte-wise compare unsafe for a whole record and safe over that run. The right shape for large arrays is a byte-compare over any contiguous padding-free run — O(1) code, no cap — and it needs a PXXMemCmp that does not exist (builtinheap.pas has PXXMemMove, PXXMemZero and PXXMemCopy only). Filed as feature-a-a-byte-compare-over-padding-free-runs-would-retire-the-record-compare-unroll-cap so it is ranked rather than buried in a resolved ticket's body. Still refused, unchanged: dynamic-array members (a handle, not inline storage), bit-fields, tySet / tyVariant / frozen-string / tyExtended members, and any record whose fields overlap.

Gate

test-record-equality-cross-target gains ten array rows and a two-armed assertion on the warning: it must fire for test/record_equality_over_unroll_cap.pas (101 leaves) and must be silent for the under-cap fixture. Both arms positive-controlled by pointing them at the wrong log and confirming RED.

Log